VizTooltips: Remove old tooltips and annotations (#84420)

Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
This commit is contained in:
Leon Sorokin 2024-04-02 15:32:46 -05:00 committed by GitHub
parent c9ab4e3a9e
commit d601acac3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 408 additions and 4243 deletions

View File

@ -1166,9 +1166,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "8"],
[0, 0, 0, "Do not use any type assertions.", "9"]
],
"public/app/core/components/GraphNG/hooks.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/core/components/NestedFolderPicker/Trigger.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
@ -5872,29 +5869,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"]
],
"public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"]
],
"public/app/plugins/panel/timeseries/plugins/annotations/AnnotationTooltip.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"]
],
"public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
@ -5904,10 +5878,6 @@ exports[`better eslint`] = {
"public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/timeseries/plugins/styles.ts:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/plugins/panel/traces/TracesPanel.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
@ -5927,9 +5897,6 @@ exports[`better eslint`] = {
"public/app/plugins/panel/xychart/ManualEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/xychart/TooltipView.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/xychart/XYChartPanel.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]

View File

@ -13,15 +13,22 @@ interface VizTooltipContentProps {
children?: ReactNode;
scrollable?: boolean;
isPinned: boolean;
maxHeight?: number;
}
export const VizTooltipContent = ({ items, children, isPinned, scrollable = false }: VizTooltipContentProps) => {
export const VizTooltipContent = ({
items,
children,
isPinned,
scrollable = false,
maxHeight,
}: VizTooltipContentProps) => {
const styles = useStyles2(getStyles);
const scrollableStyle: CSSProperties = scrollable
? {
maxHeight: 400,
overflowY: 'auto',
maxHeight: maxHeight,
overflowY: 'scroll',
}
: {};

View File

@ -4,7 +4,7 @@ import uPlot, { Cursor, Band, Hooks, Select, AlignedData, Padding, Series } from
import { DataFrame, DefaultTimeZone, Field, getTimeZoneInfo, GrafanaTheme2, TimeRange, TimeZone } from '@grafana/data';
import { AxisPlacement, VizOrientation } from '@grafana/schema';
import { FacetedData, PlotConfig, PlotTooltipInterpolator } from '../types';
import { FacetedData, PlotConfig } from '../types';
import { DEFAULT_PLOT_CONFIG, getStackingBands, pluginLog, StackingGroup } from '../utils';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
@ -30,9 +30,11 @@ type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData;
type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData;
export class UPlotConfigBuilder {
readonly uid = Math.random().toString(36).slice(2);
series: UPlotSeriesBuilder[] = [];
private axes: Record<string, UPlotAxisBuilder> = {};
private scales: UPlotScaleBuilder[] = [];
readonly scales: UPlotScaleBuilder[] = [];
private bands: Band[] = [];
private stackingGroups: StackingGroup[] = [];
private cursor: Cursor | undefined;
@ -40,13 +42,10 @@ export class UPlotConfigBuilder {
private hasLeftAxis = false;
private hooks: Hooks.Arrays = {};
private tz: string | undefined = undefined;
private sync = false;
private mode: uPlot.Mode = 1;
private frames: DataFrame[] | undefined = undefined;
// to prevent more than one threshold per scale
private thresholds: Record<string, UPlotThresholdOptions> = {};
// Custom handler for closest datapoint and series lookup
private tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined;
private padding?: Padding = undefined;
private cachedConfig?: PlotConfig;
@ -155,14 +154,6 @@ export class UPlotConfigBuilder {
return this.stackingGroups;
}
setTooltipInterpolator(interpolator: PlotTooltipInterpolator) {
this.tooltipInterpolator = interpolator;
}
getTooltipInterpolator() {
return this.tooltipInterpolator;
}
setPrepData(prepData: PreDataStacked) {
this.prepData = (frames) => {
this.frames = frames;
@ -170,14 +161,6 @@ export class UPlotConfigBuilder {
};
}
setSync() {
this.sync = true;
}
hasSync() {
return this.sync;
}
setPadding(padding: Padding) {
this.padding = padding;
}
@ -301,8 +284,6 @@ type UPlotConfigPrepOpts<T extends Record<string, unknown> = {}> = {
renderers?: Renderers;
tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps;
tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps;
// Identifies the shared key for uPlot cursor sync
eventsScope?: string;
hoverProximity?: number;
orientation?: VizOrientation;
} & T;

View File

@ -1,160 +0,0 @@
import { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { CartesianCoords2D, DashboardCursorSync } from '@grafana/data';
import { positionTooltip } from '../plugins/TooltipPlugin';
import { UPlotConfigBuilder } from './UPlotConfigBuilder';
export type HoverEvent = {
xIndex: number;
yIndex: number;
pageX: number;
pageY: number;
};
type SetupConfigParams = {
config: UPlotConfigBuilder;
onUPlotClick: () => void;
setFocusedSeriesIdx: Dispatch<SetStateAction<number | null>>;
setFocusedPointIdx: Dispatch<SetStateAction<number | null>>;
setCoords: Dispatch<SetStateAction<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>>;
setHover: Dispatch<SetStateAction<HoverEvent | undefined>>;
isToolTipOpen: MutableRefObject<boolean>;
isActive: boolean;
setIsActive: Dispatch<SetStateAction<boolean>>;
sync?: (() => DashboardCursorSync) | undefined;
};
// This applies config hooks to setup tooltip listener. Ideally this could happen in the same `prepConfig` function
// however the GraphNG structures do not allow access to the `setHover` callback
export const addTooltipSupport = ({
config,
onUPlotClick,
setFocusedSeriesIdx,
setFocusedPointIdx,
setCoords,
setHover,
isToolTipOpen,
isActive,
setIsActive,
sync,
}: SetupConfigParams): UPlotConfigBuilder => {
// Ensure tooltip is closed on config changes
isToolTipOpen.current = false;
const onMouseEnter = () => {
if (setIsActive) {
setIsActive(true);
}
};
const onMouseLeave = () => {
if (!isToolTipOpen.current) {
setCoords(null);
if (setIsActive) {
setIsActive(false);
}
}
};
let ref_parent: HTMLElement | null = null;
let ref_over: HTMLElement | null = null;
config.addHook('init', (u) => {
ref_parent = u.root.parentElement;
ref_over = u.over;
ref_parent?.addEventListener('click', onUPlotClick);
ref_over.addEventListener('mouseleave', onMouseLeave);
ref_over.addEventListener('mouseenter', onMouseEnter);
if (sync && sync() === DashboardCursorSync.Crosshair) {
u.root.classList.add('shared-crosshair');
}
});
const clearPopupIfOpened = () => {
if (isToolTipOpen.current) {
setCoords(null);
onUPlotClick();
}
};
config.addHook('drawClear', clearPopupIfOpened);
config.addHook('destroy', () => {
ref_parent?.removeEventListener('click', onUPlotClick);
ref_over?.removeEventListener('mouseleave', onMouseLeave);
ref_over?.removeEventListener('mouseenter', onMouseEnter);
clearPopupIfOpened();
});
let rect: DOMRect;
// rect of .u-over (grid area)
config.addHook('syncRect', (u, r) => {
rect = r;
});
const tooltipInterpolator = config.getTooltipInterpolator();
if (tooltipInterpolator) {
config.addHook('setCursor', (u) => {
if (isToolTipOpen.current) {
return;
}
tooltipInterpolator(
setFocusedSeriesIdx,
setFocusedPointIdx,
(clear) => {
if (clear) {
setCoords(null);
return;
}
if (!rect) {
return;
}
const { x, y } = positionTooltip(u, rect);
if (x !== undefined && y !== undefined) {
setCoords({ canvas: { x: u.cursor.left!, y: u.cursor.top! }, viewport: { x, y } });
}
},
u
);
});
}
config.addHook('setLegend', (u) => {
if (!isToolTipOpen.current && !tooltipInterpolator) {
setFocusedPointIdx(u.legend.idx!);
}
if (u.cursor.idxs != null) {
for (let i = 0; i < u.cursor.idxs.length; i++) {
const sel = u.cursor.idxs[i];
if (sel != null) {
const hover: HoverEvent = {
xIndex: sel,
yIndex: 0,
pageX: rect.left + u.cursor.left!,
pageY: rect.top + u.cursor.top!,
};
if (!isToolTipOpen.current || !hover) {
setHover(hover);
}
return; // only show the first one
}
}
}
});
config.addHook('setSeries', (_, idx) => {
if (!isToolTipOpen.current) {
setFocusedSeriesIdx(idx);
}
});
return config;
};

View File

@ -17,14 +17,13 @@ import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
interface EventBusPluginProps {
config: UPlotConfigBuilder;
eventBus: EventBus;
sync: () => boolean;
frame?: DataFrame;
}
/**
* @alpha
*/
export const EventBusPlugin = ({ config, eventBus, sync, frame }: EventBusPluginProps) => {
export const EventBusPlugin = ({ config, eventBus, frame }: EventBusPluginProps) => {
const frameRef = useRef<DataFrame | undefined>(frame);
frameRef.current = frame;
@ -51,7 +50,7 @@ export const EventBusPlugin = ({ config, eventBus, sync, frame }: EventBusPlugin
config.addHook('setLegend', () => {
let viaSync = u!.cursor.event == null;
if (!viaSync && sync()) {
if (!viaSync) {
let dataIdx = u!.cursor.idxs!.find((v) => v != null);
if (dataIdx == null) {

View File

@ -1,333 +0,0 @@
import { css } from '@emotion/css';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useMountedState } from 'react-use';
import uPlot from 'uplot';
import {
arrayUtils,
CartesianCoords2D,
DashboardCursorSync,
DataFrame,
FALLBACK_COLOR,
FieldType,
formattedValueToString,
getDisplayProcessor,
getFieldDisplayName,
GrafanaTheme2,
TimeZone,
} from '@grafana/data';
import { TooltipDisplayMode, SortOrder } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
import { Portal } from '../../Portal/Portal';
import { SeriesTable, SeriesTableRowProps, VizTooltipContainer } from '../../VizTooltip';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { findMidPointYPosition, pluginLog } from '../utils';
interface TooltipPluginProps {
timeZone: TimeZone;
data: DataFrame;
frames?: DataFrame[];
config: UPlotConfigBuilder;
mode?: TooltipDisplayMode;
sortOrder?: SortOrder;
sync?: () => DashboardCursorSync;
// 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;
/**
* @alpha
*/
export const TooltipPlugin = ({
mode = TooltipDisplayMode.Single,
sortOrder = SortOrder.None,
sync,
timeZone,
config,
renderTooltip,
...otherProps
}: TooltipPluginProps) => {
const plotInstance = useRef<uPlot>();
const theme = useTheme2();
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
const [focusedPointIdxs, setFocusedPointIdxs] = useState<Array<number | null>>([]);
const [coords, setCoords] = useState<CartesianCoords2D | null>(null);
const [isActive, setIsActive] = useState<boolean>(false);
const isMounted = useMountedState();
let parentWithFocus: HTMLElement | null = null;
const pluginId = `TooltipPlugin`;
const style = useStyles2(getStyles);
// Debug logs
useEffect(() => {
pluginLog(pluginId, true, `Focused series: ${focusedSeriesIdx}, focused point: ${focusedPointIdx}`);
}, [focusedPointIdx, focusedSeriesIdx]);
// Add uPlot hooks to the config, or re-add when the config changed
useLayoutEffect(() => {
let bbox: DOMRect | undefined = undefined;
const plotEnter = () => {
if (!isMounted()) {
return;
}
setIsActive(true);
plotInstance.current?.root.classList.add('plot-active');
};
const plotLeave = () => {
if (!isMounted()) {
return;
}
setCoords(null);
setIsActive(false);
plotInstance.current?.root.classList.remove('plot-active');
};
// cache uPlot plotting area bounding box
config.addHook('syncRect', (u, rect) => (bbox = rect));
config.addHook('init', (u) => {
plotInstance.current = u;
u.over.addEventListener('mouseenter', plotEnter);
u.over.addEventListener('mouseleave', plotLeave);
parentWithFocus = u.root.closest('[tabindex]');
if (parentWithFocus) {
parentWithFocus.addEventListener('focus', plotEnter);
parentWithFocus.addEventListener('blur', plotLeave);
}
if (sync && sync() === DashboardCursorSync.Crosshair) {
u.root.classList.add('shared-crosshair');
}
});
const tooltipInterpolator = config.getTooltipInterpolator();
if (tooltipInterpolator) {
// Custom toolitp positioning
config.addHook('setCursor', (u) => {
tooltipInterpolator(
setFocusedSeriesIdx,
setFocusedPointIdx,
(clear) => {
if (clear) {
setCoords(null);
return;
}
if (!bbox) {
return;
}
const { x, y } = positionTooltip(u, bbox);
if (x !== undefined && y !== undefined) {
setCoords({ x, y });
}
},
u
);
});
} else {
config.addHook('setLegend', (u) => {
if (!isMounted()) {
return;
}
setFocusedPointIdx(u.legend.idx!);
setFocusedPointIdxs(u.legend.idxs!.slice());
});
// default series/datapoint idx retireval
config.addHook('setCursor', (u) => {
if (!bbox || !isMounted()) {
return;
}
const { x, y } = positionTooltip(u, bbox);
if (x !== undefined && y !== undefined) {
setCoords({ x, y });
} else {
setCoords(null);
}
});
config.addHook('setSeries', (_, idx) => {
if (!isMounted()) {
return;
}
setFocusedSeriesIdx(idx);
});
}
return () => {
setCoords(null);
if (plotInstance.current) {
plotInstance.current.over.removeEventListener('mouseleave', plotLeave);
plotInstance.current.over.removeEventListener('mouseenter', plotEnter);
if (parentWithFocus) {
parentWithFocus.removeEventListener('focus', plotEnter);
parentWithFocus.removeEventListener('blur', plotLeave);
}
}
};
}, [config, setCoords, setIsActive, setFocusedPointIdx, setFocusedPointIdxs]);
if (focusedPointIdx === null || (!isActive && sync && sync() === DashboardCursorSync.Crosshair)) {
return null;
}
// GraphNG expects aligned data, let's take field 0 as x field. FTW
let xField = otherProps.data.fields[0];
if (!xField) {
return null;
}
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
let tooltip: React.ReactNode = null;
let xVal = xFieldFmt(xField!.values[focusedPointIdx]).text;
if (!renderTooltip) {
// when interacting with a point in single mode
if (mode === TooltipDisplayMode.Single && focusedSeriesIdx !== null) {
const field = otherProps.data.fields[focusedSeriesIdx];
if (!field) {
return null;
}
const dataIdx = focusedPointIdxs?.[focusedSeriesIdx] ?? focusedPointIdx;
xVal = xFieldFmt(xField!.values[dataIdx]).text;
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
const display = fieldFmt(field.values[dataIdx]);
tooltip = (
<SeriesTable
series={[
{
color: display.color || FALLBACK_COLOR,
label: getFieldDisplayName(field, otherProps.data, otherProps.frames),
value: display ? formattedValueToString(display) : null,
},
]}
timestamp={xVal}
/>
);
}
if (mode === TooltipDisplayMode.Multi) {
let series: SeriesTableRowProps[] = [];
const frame = otherProps.data;
const fields = frame.fields;
const sortIdx: unknown[] = [];
for (let i = 0; i < fields.length; i++) {
const field = frame.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 = otherProps.data.fields[i].values[focusedPointIdxs[i]!];
const display = field.display!(v);
sortIdx.push(v);
series.push({
color: display.color || FALLBACK_COLOR,
label: getFieldDisplayName(field, frame, otherProps.frames),
value: display ? formattedValueToString(display) : null,
isActive: focusedSeriesIdx === i,
});
}
if (sortOrder !== SortOrder.None) {
// create sort reference series array, as Array.sort() mutates the original array
const sortRef = [...series];
const sortFn = arrayUtils.sortValues(sortOrder);
series.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]);
});
}
tooltip = <SeriesTable series={series} timestamp={xVal} />;
}
} else {
tooltip = renderTooltip(otherProps.data, focusedSeriesIdx, focusedPointIdx);
}
return (
<Portal className={isActive ? style.tooltipWrapper : undefined}>
{tooltip && coords && (
<VizTooltipContainer position={{ x: coords.x, y: coords.y }} offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}>
{tooltip}
</VizTooltipContainer>
)}
</Portal>
);
};
function isCursorOutsideCanvas({ left, top }: uPlot.Cursor, canvas: DOMRect) {
if (left === undefined || top === undefined) {
return false;
}
return left < 0 || left > canvas.width || top < 0 || top > canvas.height;
}
/**
* Given uPlot cursor position, figure out position of the tooltip withing the canvas bbox
* Tooltip is positioned relatively to a viewport
* @internal
**/
export function positionTooltip(u: uPlot, bbox: DOMRect) {
let x, y;
const cL = u.cursor.left || 0;
const cT = u.cursor.top || 0;
if (isCursorOutsideCanvas(u.cursor, bbox)) {
const idx = u.posToIdx(cL);
// when cursor outside of uPlot's canvas
if (cT < 0 || cT > bbox.height) {
let pos = findMidPointYPosition(u, idx);
if (pos) {
y = bbox.top + pos;
if (cL >= 0 && cL <= bbox.width) {
// find x-scale position for a current cursor left position
x = bbox.left + u.valToPos(u.data[0][u.posToIdx(cL)], u.series[0].scale!);
}
}
}
} else {
x = bbox.left + cL;
y = bbox.top + cT;
}
return { x, y };
}
const getStyles = (theme: GrafanaTheme2) => ({
tooltipWrapper: css({
'z-index': theme.zIndex.portal + 1 + ' !important',
}),
});

View File

@ -4,6 +4,7 @@ import { createPortal } from 'react-dom';
import uPlot from 'uplot';
import { GrafanaTheme2 } from '@grafana/data';
import { DashboardCursorSync } from '@grafana/schema';
import { useStyles2 } from '../../../themes';
import { getPortalContainer } from '../../Portal/Portal';
@ -29,7 +30,8 @@ interface TooltipPlugin2Props {
config: UPlotConfigBuilder;
hoverMode: TooltipHoverMode;
syncTooltip?: () => boolean;
syncMode?: DashboardCursorSync;
syncScope?: string;
// x only
queryZoom?: (range: { from: number; to: number }) => void;
@ -48,7 +50,6 @@ interface TooltipPlugin2Props {
) => React.ReactNode;
maxWidth?: number;
maxHeight?: number;
}
interface TooltipContainerState {
@ -108,8 +109,8 @@ export const TooltipPlugin2 = ({
clientZoom = false,
queryZoom,
maxWidth,
maxHeight,
syncTooltip = () => false,
syncMode = DashboardCursorSync.Off,
syncScope = 'global', // eventsScope
}: TooltipPlugin2Props) => {
const domRef = useRef<HTMLDivElement>(null);
const portalRoot = useRef<HTMLElement | null>(null);
@ -123,8 +124,7 @@ export const TooltipPlugin2 = ({
const sizeRef = useRef<TooltipContainerSize>();
maxWidth = isPinned ? DEFAULT_TOOLTIP_WIDTH : maxWidth ?? DEFAULT_TOOLTIP_WIDTH;
maxHeight ??= DEFAULT_TOOLTIP_HEIGHT;
const styles = useStyles2(getStyles, maxWidth, maxHeight);
const styles = useStyles2(getStyles, maxWidth);
const renderRef = useRef(render);
renderRef.current = render;
@ -159,9 +159,20 @@ export const TooltipPlugin2 = ({
let plotVisible = false;
const syncTooltip = syncMode === DashboardCursorSync.Tooltip;
if (syncMode !== DashboardCursorSync.Off && config.scales[0].props.isTime) {
config.setCursor({
sync: {
key: syncScope,
scales: ['x', null],
},
});
}
const updateHovering = () => {
if (viaSync) {
_isHovering = plotVisible && _someSeriesIdx && syncTooltip();
_isHovering = plotVisible && _someSeriesIdx && syncTooltip;
} else {
_isHovering = closestSeriesIdx != null || (hoverMode === TooltipHoverMode.xAll && _someSeriesIdx);
}
@ -570,7 +581,7 @@ export const TooltipPlugin2 = ({
return null;
};
const getStyles = (theme: GrafanaTheme2, maxWidth?: number, maxHeight?: number) => ({
const getStyles = (theme: GrafanaTheme2, maxWidth?: number) => ({
tooltipWrapper: css({
top: 0,
left: 0,
@ -583,8 +594,6 @@ const getStyles = (theme: GrafanaTheme2, maxWidth?: number, maxHeight?: number)
boxShadow: theme.shadows.z2,
userSelect: 'text',
maxWidth: maxWidth ?? 'none',
maxHeight: maxHeight ?? 'none',
overflowY: 'auto',
}),
pinned: css({
boxShadow: theme.shadows.z3,

View File

@ -1,127 +0,0 @@
import { useLayoutEffect } from 'react';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
interface ZoomPluginProps {
onZoom: (range: { from: number; to: number }) => void;
withZoomY?: boolean;
config: UPlotConfigBuilder;
}
// min px width that triggers zoom
const MIN_ZOOM_DIST = 5;
const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey;
/**
* @alpha
*/
export const ZoomPlugin = ({ onZoom, config, withZoomY = false }: ZoomPluginProps) => {
useLayoutEffect(() => {
let yZoomed = false;
let yDrag = false;
if (withZoomY) {
config.addHook('init', (u) => {
u.over!.addEventListener(
'mousedown',
(e) => {
if (!maybeZoomAction(e)) {
return;
}
if (e.button === 0 && e.shiftKey) {
yDrag = true;
u.cursor!.drag!.x = false;
u.cursor!.drag!.y = true;
let onUp = (e: MouseEvent) => {
u.cursor!.drag!.x = true;
u.cursor!.drag!.y = false;
document.removeEventListener('mouseup', onUp, true);
};
document.addEventListener('mouseup', onUp, true);
}
},
true
);
});
}
config.addHook('setSelect', (u) => {
const isXAxisHorizontal = u.scales.x.ori === 0;
if (maybeZoomAction(u.cursor!.event)) {
if (withZoomY && yDrag) {
if (u.select.height >= MIN_ZOOM_DIST) {
for (let key in u.scales!) {
if (key !== 'x') {
const maxY = isXAxisHorizontal
? u.posToVal(u.select.top, key)
: u.posToVal(u.select.left + u.select.width, key);
const minY = isXAxisHorizontal
? u.posToVal(u.select.top + u.select.height, key)
: u.posToVal(u.select.left, key);
u.setScale(key, { min: minY, max: maxY });
}
}
yZoomed = true;
}
yDrag = false;
} else {
if (u.select.width >= MIN_ZOOM_DIST) {
const minX = isXAxisHorizontal
? u.posToVal(u.select.left, 'x')
: u.posToVal(u.select.top + u.select.height, 'x');
const maxX = isXAxisHorizontal
? u.posToVal(u.select.left + u.select.width, 'x')
: u.posToVal(u.select.top, 'x');
onZoom({ from: minX, to: maxX });
yZoomed = false;
}
}
}
// manually hide selected region (since cursor.drag.setScale = false)
u.setSelect({ left: 0, width: 0, top: 0, height: 0 }, false);
});
config.setCursor({
bind: {
dblclick: (u) => () => {
if (!maybeZoomAction(u.cursor!.event)) {
return null;
}
if (withZoomY && yZoomed) {
for (let key in u.scales!) {
if (key !== 'x') {
// @ts-ignore (this is not typed correctly in uPlot, assigning nulls means auto-scale / reset)
u.setScale(key, { min: null, max: null });
}
}
yZoomed = false;
} else {
let xScale = u.scales.x;
const frTs = xScale.min!;
const toTs = xScale.max!;
const pad = (toTs - frTs) / 2;
onZoom({ from: frTs - pad, to: toTs + pad });
}
return null;
},
},
});
}, [config]);
return null;
};

View File

@ -1,5 +1,3 @@
export { ZoomPlugin } from './ZoomPlugin';
export { TooltipPlugin } from './TooltipPlugin';
export { TooltipPlugin2 } from './TooltipPlugin2';
export { EventBusPlugin } from './EventBusPlugin';
export { KeyboardPlugin } from './KeyboardPlugin';

View File

@ -13,10 +13,6 @@ export type PlotConfig = Pick<
'mode' | 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding'
>;
export interface PlotPluginProps {
id: string;
}
export type FacetValues = any[];
export type FacetSeries = FacetValues[];
export type FacetedData = [_: null, ...series: FacetSeries];

View File

@ -3,21 +3,7 @@ import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, Stacki
import { preparePlotFrame } from '..';
import { getStackingGroups, preparePlotData2, timeFormatToTemplate } from './utils';
describe('timeFormatToTemplate', () => {
it.each`
format | expected
${'HH:mm:ss'} | ${'{HH}:{mm}:{ss}'}
${'HH:mm'} | ${'{HH}:{mm}'}
${'MM/DD HH:mm'} | ${'{MM}/{DD} {HH}:{mm}'}
${'MM/DD'} | ${'{MM}/{DD}'}
${'YYYY-MM'} | ${'{YYYY}-{MM}'}
${'YYYY'} | ${'{YYYY}'}
`('should convert $format to $expected', ({ format, expected }) => {
expect(timeFormatToTemplate(format)).toEqual(expected);
});
});
import { getStackingGroups, preparePlotData2 } from './utils';
describe('preparePlotData2', () => {
const df = new MutableDataFrame({

View File

@ -8,12 +8,6 @@ import { createLogger } from '../../utils/logger';
import { buildScaleKey } from './internal';
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
export function timeFormatToTemplate(f: string) {
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`);
}
const paddingSide: PaddingSide = (u, side, sidesWithAxes) => {
let hasCrossAxis = side % 2 ? sidesWithAxes[0] || sidesWithAxes[2] : sidesWithAxes[1] || sidesWithAxes[3];

View File

@ -74,13 +74,6 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
"stroke": [Function],
"width": [Function],
},
"sync": {
"key": "__global_",
"scales": [
"x",
null,
],
},
},
"focus": {
"alpha": 1,

View File

@ -1,9 +1,7 @@
import {
createTheme,
DashboardCursorSync,
DataFrame,
DefaultTimeZone,
// EventBusSrv,
FieldColorModeId,
FieldConfig,
FieldMatcherID,
@ -215,7 +213,6 @@ describe('GraphNG utils', () => {
theme: createTheme(),
timeZones: [DefaultTimeZone],
getTimeRange: getDefaultTimeRange,
sync: () => DashboardCursorSync.Tooltip,
allFrames: [frame!],
}).getConfig();
expect(result).toMatchSnapshot();

View File

@ -19,7 +19,6 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
declare context: React.ContextType<typeof PanelContextRoot>;
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const { eventsScope, sync } = this.context;
const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props;
return preparePlotConfigBuilder({
@ -27,12 +26,10 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
theme,
timeZones: Array.isArray(timeZone) ? timeZone : [timeZone],
getTimeRange,
sync,
allFrames,
renderers,
tweakScale,
tweakAxis,
eventsScope,
});
};

View File

@ -2,7 +2,6 @@ import { isNumber } from 'lodash';
import uPlot from 'uplot';
import {
DashboardCursorSync,
DataFrame,
FieldConfig,
FieldType,
@ -71,19 +70,15 @@ const defaultConfig: GraphFieldConfig = {
axisPlacement: AxisPlacement.Auto,
};
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
sync?: () => DashboardCursorSync;
}> = ({
export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
frame,
theme,
timeZones,
getTimeRange,
sync,
allFrames,
renderers,
tweakScale = (opts) => opts,
tweakAxis = (opts) => opts,
eventsScope = '__global_',
}) => {
const builder = new UPlotConfigBuilder(timeZones[0]);
@ -103,7 +98,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
}
const xScaleKey = 'x';
let yScaleKey = '';
const xFieldAxisPlacement =
xField.config.custom?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden;
@ -264,10 +258,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
)
);
if (!yScaleKey) {
yScaleKey = scaleKey;
}
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
let axisColor: uPlot.Axis.Stroke | undefined;
@ -543,8 +533,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
r.init(builder, fieldIndices);
});
builder.scaleKeys = [xScaleKey, yScaleKey];
// if hovered value is null, how far we may scan left/right to hover nearest non-null
const hoverProximityPx = 15;
@ -597,20 +585,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
},
};
if (sync && sync() !== DashboardCursorSync.Off) {
cursor.sync = {
key: eventsScope,
scales: [xScaleKey, null],
};
}
builder.setSync();
builder.setCursor(cursor);
return builder;
};
export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
const originNames = new Map<string, number>();
frame.fields.forEach((field, i) => {
const origin = field.state?.origin;

View File

@ -72,10 +72,10 @@ export function addTooltipOptions<T extends OptionsWithTooltip>(
path: 'tooltip.maxHeight',
name: 'Max height',
category,
defaultValue: 600,
defaultValue: undefined,
settings: {
integer: true,
},
showIf: (options: T) => false, //options.tooltip?.mode !== TooltipDisplayMode.None,
showIf: (options: T) => options.tooltip?.mode !== TooltipDisplayMode.None,
});
}

View File

@ -13,8 +13,8 @@ import {
TimeRange,
TimeZone,
} from '@grafana/data';
import { VizLegendOptions } from '@grafana/schema';
import { Themeable2, PanelContextRoot, VizLayout } from '@grafana/ui';
import { DashboardCursorSync, VizLegendOptions } from '@grafana/schema';
import { Themeable2, VizLayout } from '@grafana/ui';
import { UPlotChart } from '@grafana/ui/src/components/uPlot/Plot';
import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
import { Renderers, UPlotConfigBuilder } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder';
@ -49,6 +49,7 @@ export interface GraphNGProps extends Themeable2 {
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null;
replaceVariables: InterpolateFunction;
dataLinkPostProcessor?: DataLinkPostProcessor;
cursorSync?: DashboardCursorSync;
/**
* needed for propsToDiff to re-init the plot & config
@ -86,7 +87,6 @@ export interface GraphNGState {
* "Time as X" core component, expects ascending x
*/
export class GraphNG extends Component<GraphNGProps, GraphNGState> {
static contextType = PanelContextRoot;
private plotInstance: React.RefObject<uPlot>;
constructor(props: GraphNGProps) {
@ -173,17 +173,23 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> {
}
componentDidUpdate(prevProps: GraphNGProps) {
const { frames, structureRev, timeZone, propsToDiff } = this.props;
const { frames, structureRev, timeZone, cursorSync, propsToDiff } = this.props;
const propsChanged = !sameProps(prevProps, this.props, propsToDiff);
if (frames !== prevProps.frames || propsChanged || timeZone !== prevProps.timeZone) {
if (
frames !== prevProps.frames ||
propsChanged ||
timeZone !== prevProps.timeZone ||
cursorSync !== prevProps.cursorSync
) {
let newState = this.prepState(this.props, false);
if (newState) {
const shouldReconfig =
this.state.config === undefined ||
timeZone !== prevProps.timeZone ||
cursorSync !== prevProps.cursorSync ||
structureRev !== prevProps.structureRev ||
!structureRev ||
propsChanged;

View File

@ -79,13 +79,6 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
"stroke": [Function],
"width": [Function],
},
"sync": {
"key": "__global_",
"scales": [
"x",
null,
],
},
},
"focus": {
"alpha": 1,

View File

@ -1,44 +0,0 @@
import React, { useCallback, useContext } from 'react';
import { DataFrame, DataFrameFieldIndex, Field } from '@grafana/data';
import { XYFieldMatchers } from './types';
/** @alpha */
interface GraphNGContextType {
mapSeriesIndexToDataFrameFieldIndex: (index: number) => DataFrameFieldIndex;
dimFields: XYFieldMatchers;
data: DataFrame;
}
/** @alpha */
export const GraphNGContext = React.createContext<GraphNGContextType>({} as GraphNGContextType);
/**
* @alpha
* Exposes API for data frame inspection in Plot plugins
*/
export const useGraphNGContext = () => {
const { data, dimFields, mapSeriesIndexToDataFrameFieldIndex } = useContext<GraphNGContextType>(GraphNGContext);
const getXAxisField = useCallback(() => {
const xFieldMatcher = dimFields.x;
let xField: Field | null = null;
for (let j = 0; j < data.fields.length; j++) {
if (xFieldMatcher(data.fields[j], data, [data])) {
xField = data.fields[j];
break;
}
}
return xField;
}, [data, dimFields]);
return {
dimFields,
mapSeriesIndexToDataFrameFieldIndex,
getXAxisField,
alignedData: data,
};
};

View File

@ -1,6 +1,5 @@
import {
createTheme,
DashboardCursorSync,
DataFrame,
DefaultTimeZone,
FieldColorModeId,
@ -214,7 +213,6 @@ describe('GraphNG utils', () => {
theme: createTheme(),
timeZones: [DefaultTimeZone],
getTimeRange: getDefaultTimeRange,
sync: () => DashboardCursorSync.Tooltip,
allFrames: [frame!],
}).getConfig();
expect(result).toMatchSnapshot();

View File

@ -1,7 +1,6 @@
import React, { Component } from 'react';
import { DataFrame, TimeRange } from '@grafana/data';
import { PanelContextRoot } from '@grafana/ui/src/components/PanelChrome/PanelContext';
import { hasVisibleLegendSeries, PlotLegend } from '@grafana/ui/src/components/uPlot/PlotLegend';
import { UPlotConfigBuilder } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder';
import { withTheme2 } from '@grafana/ui/src/themes/ThemeContext';
@ -15,11 +14,7 @@ const propsToDiff: Array<string | PropDiffFn> = ['legend', 'options', 'theme'];
type TimeSeriesProps = Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'>;
export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
static contextType = PanelContextRoot;
declare context: React.ContextType<typeof PanelContextRoot>;
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const { eventsScope, sync } = this.context;
const { theme, timeZone, options, renderers, tweakAxis, tweakScale } = this.props;
return preparePlotConfigBuilder({
@ -27,12 +22,10 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
theme,
timeZones: Array.isArray(timeZone) ? timeZone : [timeZone],
getTimeRange,
sync,
allFrames,
renderers,
tweakScale,
tweakAxis,
eventsScope,
hoverProximity: options?.tooltip?.hoverProximity,
orientation: options?.orientation,
});

View File

@ -2,7 +2,6 @@ import { isNumber } from 'lodash';
import uPlot from 'uplot';
import {
DashboardCursorSync,
DataFrame,
FieldConfig,
FieldType,
@ -73,19 +72,15 @@ const defaultConfig: GraphFieldConfig = {
axisPlacement: AxisPlacement.Auto,
};
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
sync?: () => DashboardCursorSync;
}> = ({
export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
frame,
theme,
timeZones,
getTimeRange,
sync,
allFrames,
renderers,
tweakScale = (opts) => opts,
tweakAxis = (opts) => opts,
eventsScope = '__global_',
hoverProximity,
orientation = VizOrientation.Horizontal,
}) => {
@ -555,8 +550,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
r.init(builder, fieldIndices);
});
builder.scaleKeys = [xScaleKey, yScaleKey];
// if hovered value is null, how far we may scan left/right to hover nearest non-null
const DEFAULT_HOVER_NULL_PROXIMITY = 15;
const DEFAULT_FOCUS_PROXIMITY = 30;
@ -586,21 +579,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
},
};
if (xField.type === FieldType.time && sync && sync() !== DashboardCursorSync.Off) {
cursor.sync = {
key: eventsScope,
scales: [xScaleKey, null],
// match: [() => true, () => false],
};
}
builder.setSync();
builder.setCursor(cursor);
return builder;
};
export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
const originNames = new Map<string, number>();
frame.fields.forEach((field, i) => {
const origin = field.state?.origin;

View File

@ -2,7 +2,7 @@ import React from 'react';
import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data';
import { VisibilityMode, TimelineValueAlignment, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
import { PanelContext, PanelContextRoot, UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui';
import { UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui';
import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG';
@ -24,10 +24,6 @@ export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsT
const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue', 'tooltip'];
export class TimelineChart extends React.Component<TimelineProps> {
declare context: React.ContextType<typeof PanelContextRoot>;
static contextType = PanelContextRoot;
panelContext: PanelContext | undefined;
getValueColor = (frameIdx: number, fieldIdx: number, value: unknown) => {
const field = this.props.frames[frameIdx].fields[fieldIdx];
@ -42,13 +38,9 @@ export class TimelineChart extends React.Component<TimelineProps> {
};
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
this.panelContext = this.context;
const { sync } = this.panelContext;
return preparePlotConfigBuilder({
frame: alignedFrame,
getTimeRange,
sync,
allFrames: this.props.frames,
...this.props,
@ -92,7 +84,6 @@ export class TimelineChart extends React.Component<TimelineProps> {
prepConfig={this.prepConfig}
propsToDiff={propsToDiff}
renderLegend={this.renderLegend}
dataLinkPostProcessor={this.panelContext?.dataLinkPostProcessor}
/>
);
}

View File

@ -53,8 +53,6 @@ export interface TimelineCoreOptions {
getTimeRange: () => TimeRange;
formatValue?: (seriesIdx: number, value: unknown) => string;
getFieldConfig: (seriesIdx: number) => StateTimeLineFieldConfig | StatusHistoryFieldConfig;
onHover: (seriesIdx: number, valueIdx: number, rect: Rect) => void;
onLeave: () => void;
hoverMulti: boolean;
}
@ -78,8 +76,6 @@ export function getConfig(opts: TimelineCoreOptions) {
getTimeRange,
getValueColor,
getFieldConfig,
onHover,
onLeave,
hoverMulti,
} = opts;
@ -426,17 +422,7 @@ export function getConfig(opts: TimelineCoreOptions) {
let cx = u.cursor.left! * uPlot.pxRatio;
let cy = u.cursor.top! * uPlot.pxRatio;
let prevHovered = hoveredAtCursor;
setHovered(cx, cy, u.cursor.event == null);
if (hoveredAtCursor != null) {
if (hoveredAtCursor !== prevHovered) {
onHover(hoveredAtCursor.sidx, hoveredAtCursor.didx, hoveredAtCursor);
}
} else if (prevHovered != null) {
onLeave();
}
}
return hovered[seriesIdx]?.didx;

View File

@ -1,9 +1,5 @@
import React from 'react';
import uPlot from 'uplot';
import {
DataFrame,
DashboardCursorSync,
FALLBACK_COLOR,
Field,
FieldColorModeId,
@ -34,14 +30,7 @@ import {
HideableFieldConfig,
MappingType,
} from '@grafana/schema';
import {
FIXED_UNIT,
SeriesVisibilityChangeMode,
UPlotConfigBuilder,
UPlotConfigPrepFn,
VizLegendItem,
} from '@grafana/ui';
import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
import { FIXED_UNIT, UPlotConfigBuilder, UPlotConfigPrepFn, VizLegendItem } from '@grafana/ui';
import { preparePlotData2, getStackingGroups } from '@grafana/ui/src/components/uPlot/utils';
import { getConfig, TimelineCoreOptions } from './timeline';
@ -53,15 +42,12 @@ interface UPlotConfigOptions {
frame: DataFrame;
theme: GrafanaTheme2;
mode: TimelineMode;
sync?: () => DashboardCursorSync;
rowHeight?: number;
colWidth?: number;
showValue: VisibilityMode;
alignValue?: TimelineValueAlignment;
mergeValues?: boolean;
getValueColor: (frameIdx: number, fieldIdx: number, value: unknown) => string;
// Identifies the shared key for uPlot cursor sync
eventsScope?: string;
hoverMulti: boolean;
}
@ -83,27 +69,18 @@ const defaultConfig: PanelFieldConfig = {
fillOpacity: 80,
};
export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return SeriesVisibilityChangeMode.AppendToSelection;
}
return SeriesVisibilityChangeMode.ToggleSelection;
}
export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ({
frame,
theme,
timeZones,
getTimeRange,
mode,
sync,
rowHeight,
colWidth,
showValue,
alignValue,
mergeValues,
getValueColor,
eventsScope = '__global_',
hoverMulti,
}) => {
const builder = new UPlotConfigBuilder(timeZones[0]);
@ -154,50 +131,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
getTimeRange,
// hardcoded formatter for state values
formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)),
onHover: (seriesIndex, valueIndex) => {
hoveredSeriesIdx = seriesIndex;
hoveredDataIdx = valueIndex;
shouldChangeHover = true;
},
onLeave: () => {
hoveredSeriesIdx = null;
hoveredDataIdx = null;
shouldChangeHover = true;
},
hoverMulti,
};
let shouldChangeHover = false;
let hoveredSeriesIdx: number | null = null;
let hoveredDataIdx: number | null = null;
const coreConfig = getConfig(opts);
builder.addHook('init', coreConfig.init);
builder.addHook('drawClear', coreConfig.drawClear);
// 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
) => {
if (shouldChangeHover) {
if (hoveredSeriesIdx != null) {
updateActiveSeriesIdx(hoveredSeriesIdx);
updateActiveDatapointIdx(hoveredDataIdx);
}
shouldChangeHover = false;
}
updateTooltipPosition(hoveredSeriesIdx == null);
};
builder.setTooltipInterpolator(interpolateTooltip);
builder.setPrepData((frames) => preparePlotData2(frames[0], getStackingGroups(frames[0])));
builder.setCursor(coreConfig.cursor);
@ -274,58 +215,9 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
});
}
if (sync && sync() !== DashboardCursorSync.Off) {
let cursor: Partial<uPlot.Cursor> = {};
cursor.sync = {
key: eventsScope,
scales: [xScaleKey, null],
};
builder.setSync();
builder.setCursor(cursor);
}
return builder;
};
export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
const names = new Map<string, number>();
for (let i = 0; i < frame.fields.length; i++) {
names.set(getFieldDisplayName(frame.fields[i], frame), i);
}
return names;
}
/**
* If any sequential duplicate values exist, this will return a new array
* with the future values set to undefined.
*
* in: 1, 1,undefined, 1,2, 2,null,2,3
* out: 1,undefined,undefined,undefined,2,undefined,null,2,3
*/
export function unsetSameFutureValues(values: unknown[]): unknown[] | undefined {
let prevVal = values[0];
let clone: unknown[] | undefined = undefined;
for (let i = 1; i < values.length; i++) {
let value = values[i];
if (value === null) {
prevVal = null;
} else {
if (value === prevVal) {
if (!clone) {
clone = [...values];
}
clone[i] = undefined;
} else if (value != null) {
prevVal = value;
}
}
}
return clone;
}
function getSpanNulls(field: Field) {
let spanNulls = field.config.custom?.spanNulls;

View File

@ -1,26 +1,22 @@
import { noop } from 'lodash';
import React, { useEffect, useRef } from 'react';
import React from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { BehaviorSubject } from 'rxjs';
import { DataFrame, InterpolateFunction, TimeRange } from '@grafana/data';
import { VisibilityMode } from '@grafana/schema';
import { LegendDisplayMode, UPlotConfigBuilder, useTheme2 } from '@grafana/ui';
import { LegendDisplayMode, useTheme2 } from '@grafana/ui';
import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart';
import { TimelineMode } from 'app/core/components/TimelineChart/utils';
interface LogTimelineViewerProps {
frames: DataFrame[];
timeRange: TimeRange;
onPointerMove?: (seriesIdx: number, pointerIdx: number) => void;
}
// noop
const replaceVariables: InterpolateFunction = (v) => v;
export const LogTimelineViewer = React.memo(({ frames, timeRange, onPointerMove = noop }: LogTimelineViewerProps) => {
export const LogTimelineViewer = React.memo(({ frames, timeRange }: LogTimelineViewerProps) => {
const theme = useTheme2();
const { setupCursorTracking } = useCursorTimelinePosition(onPointerMove);
return (
<AutoSizer disableHeight>
@ -49,61 +45,10 @@ export const LogTimelineViewer = React.memo(({ frames, timeRange, onPointerMove
{ label: 'Mixed', color: theme.colors.text.secondary, yAxis: 1 },
]}
replaceVariables={replaceVariables}
>
{(builder) => {
setupCursorTracking(builder);
return null;
}}
</TimelineChart>
/>
)}
</AutoSizer>
);
});
function useCursorTimelinePosition(onPointerMove: (seriesIdx: number, pointIdx: number) => void) {
const pointerSubject = useRef(
new BehaviorSubject<{ seriesIdx: number; pointIdx: number }>({ seriesIdx: 0, pointIdx: 0 })
);
useEffect(() => {
const subscription = pointerSubject.current.subscribe(({ seriesIdx, pointIdx }) => {
onPointerMove && onPointerMove(seriesIdx, pointIdx);
});
return () => {
subscription.unsubscribe();
};
}, [onPointerMove]);
// Applies cursor tracking to the UPlot chart
const setupCursorTracking = (builder: UPlotConfigBuilder) => {
builder.setSync();
const interpolator = builder.getTooltipInterpolator();
// I found this in TooltipPlugin.tsx
if (interpolator) {
builder.addHook('setCursor', (u) => {
interpolator(
(seriesIdx) => {
if (seriesIdx) {
const currentPointer = pointerSubject.current.getValue();
pointerSubject.current.next({ ...currentPointer, seriesIdx });
}
},
(pointIdx) => {
if (pointIdx) {
const currentPointer = pointerSubject.current.getValue();
pointerSubject.current.next({ ...currentPointer, pointIdx });
}
},
() => {},
u
);
});
}
};
return { setupCursorTracking };
}
LogTimelineViewer.displayName = 'LogTimelineViewer';

View File

@ -50,7 +50,7 @@ const LokiStateHistory = ({ ruleUID }: Props) => {
instancesFilter
);
const { frameSubset, frameSubsetTimestamps, frameTimeRange } = useFrameSubset(dataFrames);
const { frameSubset, frameTimeRange } = useFrameSubset(dataFrames);
const onLogRecordLabelClick = useCallback(
(label: string) => {
@ -66,26 +66,6 @@ const LokiStateHistory = ({ ruleUID }: Props) => {
setValue('query', '');
}, [setInstancesFilter, setValue]);
const refToHighlight = useRef<HTMLElement | undefined>(undefined);
const onTimelinePointerMove = useCallback(
(seriesIdx: number, pointIdx: number) => {
// remove the highlight from the previous refToHighlight
refToHighlight.current?.classList.remove(styles.highlightedLogRecord);
const timestamp = frameSubsetTimestamps[pointIdx];
const newTimestampRef = logsRef.current.get(timestamp);
// now we have the new ref, add the styles
newTimestampRef?.classList.add(styles.highlightedLogRecord);
// keeping this here (commented) in case we decide we want to go back to this
// newTimestampRef?.scrollIntoView({ behavior: 'smooth', block: 'center' });
refToHighlight.current = newTimestampRef;
},
[frameSubsetTimestamps, styles.highlightedLogRecord]
);
if (isLoading) {
return <div>Loading...</div>;
}
@ -138,7 +118,7 @@ const LokiStateHistory = ({ ruleUID }: Props) => {
) : (
<>
<div className={styles.graphWrapper}>
<LogTimelineViewer frames={frameSubset} timeRange={frameTimeRange} onPointerMove={onTimelinePointerMove} />
<LogTimelineViewer frames={frameSubset} timeRange={frameTimeRange} />
</div>
{hasMoreInstances && (
<div className={styles.moreInstancesWarning}>

View File

@ -1,25 +1,20 @@
import React, { useMemo, useRef, useState } from 'react';
import React, { useMemo, useRef } from 'react';
import {
CartesianCoords2D,
compareDataFrameStructures,
DataFrame,
Field,
FieldColorModeId,
FieldType,
getFieldDisplayName,
PanelProps,
TimeRange,
VizOrientation,
} from '@grafana/data';
import { PanelDataErrorView, config } from '@grafana/runtime';
import { SortOrder } from '@grafana/schema';
import { PanelDataErrorView } from '@grafana/runtime';
import {
GraphGradientMode,
measureText,
PlotLegend,
Portal,
StackingMode,
TooltipDisplayMode,
UPlotConfigBuilder,
UPLOT_AXIS_FONT_SIZE,
@ -27,23 +22,17 @@ import {
useTheme2,
VizLayout,
VizLegend,
VizTooltipContainer,
TooltipPlugin2,
} from '@grafana/ui';
import { HoverEvent, addTooltipSupport } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport';
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { GraphNG, GraphNGProps, PropDiffFn } from 'app/core/components/GraphNG/GraphNG';
import { getFieldLegendItem } from 'app/core/components/TimelineChart/utils';
import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView';
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
import { Options } from './panelcfg.gen';
import { prepareBarChartDisplayValues, preparePlotConfigBuilder } from './utils';
const TOOLTIP_OFFSET = 10;
/**
* @alpha
*/
@ -73,29 +62,6 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
const theme = useTheme2();
const { dataLinkPostProcessor } = usePanelContext();
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
const isToolTipOpen = useRef<boolean>(false);
const [hover, setHover] = useState<HoverEvent | undefined>(undefined);
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null);
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
const [isActive, setIsActive] = useState<boolean>(false);
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
const onCloseToolTip = () => {
isToolTipOpen.current = false;
setCoords(null);
setShouldDisplayCloseButton(false);
};
const onUPlotClick = () => {
isToolTipOpen.current = !isToolTipOpen.current;
// Linking into useState required to re-render tooltip
setShouldDisplayCloseButton(isToolTipOpen.current);
};
const frame0Ref = useRef<DataFrame>();
const colorByFieldRef = useRef<Field>();
@ -157,51 +123,9 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
);
}
const renderTooltip = (alignedFrame: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => {
const field = seriesIdx == null ? null : alignedFrame.fields[seriesIdx];
if (field) {
const disp = getFieldDisplayName(field, alignedFrame);
seriesIdx = info.aligned.fields.findIndex((f) => disp === getFieldDisplayName(f, info.aligned));
}
const tooltipMode =
options.fullHighlight && options.stacking !== StackingMode.None ? TooltipDisplayMode.Multi : options.tooltip.mode;
const tooltipSort = options.tooltip.mode === TooltipDisplayMode.Multi ? options.tooltip.sort : SortOrder.None;
return (
<>
{shouldDisplayCloseButton && (
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<CloseButton
onClick={onCloseToolTip}
style={{
position: 'relative',
top: 'auto',
right: 'auto',
marginRight: 0,
}}
/>
</div>
)}
<DataHoverView
data={info.aligned}
rowIndex={datapointIdx}
columnIndex={seriesIdx}
sortOrder={tooltipSort}
mode={tooltipMode}
/>
</>
);
};
const renderLegend = (config: UPlotConfigBuilder) => {
const { legend } = options;
if (!config || legend.showLegend === false) {
return null;
}
@ -309,8 +233,6 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
});
};
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
return (
<GraphNG
theme={theme}
@ -329,7 +251,7 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
dataLinkPostProcessor={dataLinkPostProcessor}
>
{(config) => {
if (showNewVizTooltips && options.tooltip.mode !== TooltipDisplayMode.None) {
if (options.tooltip.mode !== TooltipDisplayMode.None) {
return (
<TooltipPlugin2
config={config}
@ -350,42 +272,11 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
);
}}
maxWidth={options.tooltip.maxWidth}
maxHeight={options.tooltip.maxHeight}
/>
);
}
if (!showNewVizTooltips && oldConfig.current !== config) {
oldConfig.current = addTooltipSupport({
config,
onUPlotClick,
setFocusedSeriesIdx,
setFocusedPointIdx,
setCoords,
setHover,
isToolTipOpen,
isActive,
setIsActive,
});
}
if (options.tooltip.mode === TooltipDisplayMode.None) {
return null;
}
return (
<Portal>
{hover && coords && focusedSeriesIdx && (
<VizTooltipContainer
position={{ x: coords.viewport.x, y: coords.viewport.y }}
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
allowPointerEvents={isToolTipOpen.current}
>
{renderTooltip(info.viz[0], focusedSeriesIdx, focusedPointIdx)}
</VizTooltipContainer>
)}
</Portal>
);
return null;
}}
</GraphNG>
);

View File

@ -10,7 +10,7 @@ import {
VizTextDisplayOptions,
VizLegendOptions,
} from '@grafana/schema';
import { measureText, PlotTooltipInterpolator } from '@grafana/ui';
import { measureText } from '@grafana/ui';
import { timeUnitSize } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
import { StackingGroup, preparePlotData2 } from '@grafana/ui/src/components/uPlot/utils';
@ -56,8 +56,6 @@ export interface BarsOptions {
formatShortValue: (seriesIdx: number, value: unknown) => string;
timeZone?: TimeZone;
text?: VizTextDisplayOptions;
onHover?: (seriesIdx: number, valueIdx: number) => void;
onLeave?: (seriesIdx: number, valueIdx: number) => void;
hoverMulti?: boolean;
legend?: VizLegendOptions;
xSpacing?: number;
@ -634,22 +632,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
u.ctx.restore();
};
// handle hover interaction with quadtree probing
const interpolateTooltip: PlotTooltipInterpolator = (
updateActiveSeriesIdx,
updateActiveDatapointIdx,
updateTooltipPosition,
u
) => {
if (hRect) {
updateActiveSeriesIdx(hRect.sidx);
updateActiveDatapointIdx(hRect.didx);
updateTooltipPosition();
} else {
updateTooltipPosition(true);
}
};
let alignedTotals: AlignedData | null = null;
function prepData(frames: DataFrame[], stackingGroups: StackingGroup[]) {
@ -673,7 +655,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
init,
drawClear,
draw,
interpolateTooltip,
prepData,
};
}

View File

@ -17,7 +17,6 @@ import {
getFieldDisplayName,
} from '@grafana/data';
import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames';
import { config as runtimeConfig } from '@grafana/runtime';
import {
AxisColorMode,
AxisPlacement,
@ -138,9 +137,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
builder.addHook('drawClear', config.drawClear);
builder.addHook('draw', config.draw);
const showNewVizTooltips = Boolean(runtimeConfig.featureToggles.newVizTooltips);
!showNewVizTooltips && builder.setTooltipInterpolator(config.interpolateTooltip);
if (xTickLabelRotation !== 0) {
// these are the amount of space we already have available between plot edge and first label
// TODO: removing these hardcoded value requires reading back uplot instance props

View File

@ -1,7 +1,7 @@
// this file is pretty much a copy-paste of TimeSeriesPanel.tsx :(
// with some extra renderers passed to the <TimeSeries> component
import React, { useMemo, useState, useCallback } from 'react';
import React, { useMemo, useState } from 'react';
import uPlot from 'uplot';
import { Field, getDisplayProcessor, PanelProps } from '@grafana/data';
@ -10,12 +10,10 @@ import { DashboardCursorSync, TooltipDisplayMode } from '@grafana/schema';
import {
EventBusPlugin,
KeyboardPlugin,
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';
@ -24,10 +22,7 @@ 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 { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin';
import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { ThresholdControlsPlugin } from '../timeseries/plugins/ThresholdControlsPlugin';
@ -53,6 +48,7 @@ export const CandlestickPanel = ({
}: CandlestickPanelProps) => {
const {
sync,
eventsScope,
canAddAnnotations,
onThresholdsChange,
canEditThresholds,
@ -63,25 +59,13 @@ export const CandlestickPanel = ({
const theme = useTheme2();
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const syncAny = useCallback(
() => sync?.() !== DashboardCursorSync.Off,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const info = useMemo(() => {
return prepareCandlestickFields(data.series, options, theme, timeRange);
}, [data.series, options, theme, timeRange]);
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
const { renderers, tweakScale, tweakAxis, shouldRenderPrice } = useMemo(() => {
let tweakScale = (opts: ScaleProps, forField: Field) => opts;
@ -264,7 +248,6 @@ export const CandlestickPanel = ({
}
const enableAnnotationCreation = Boolean(canAddAnnotations?.());
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
return (
<TimeSeries
@ -281,13 +264,16 @@ export const CandlestickPanel = ({
options={options}
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
>
{(uplotConfig, alignedFrame) => {
return (
<>
<KeyboardPlugin config={uplotConfig} />
<EventBusPlugin config={uplotConfig} sync={syncAny} eventBus={eventBus} frame={alignedFrame} />
{showNewVizTooltips ? (
{cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={uplotConfig} eventBus={eventBus} frame={alignedFrame} />
)}
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={uplotConfig}
hoverMode={
@ -295,7 +281,8 @@ export const CandlestickPanel = ({
}
queryZoom={onChangeTimeRange}
clientZoom={true}
syncTooltip={syncTooltip}
syncMode={cursorSync}
syncScope={eventsScope}
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
@ -321,88 +308,24 @@ export const CandlestickPanel = ({
isPinned={isPinned}
annotate={enableAnnotationCreation ? annotate : undefined}
scrollable={isTooltipScrollable(options.tooltip)}
maxHeight={options.tooltip.maxHeight}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
maxHeight={options.tooltip.maxHeight}
/>
) : (
<>
<ZoomPlugin config={uplotConfig} onZoom={onChangeTimeRange} withZoomY={true} />
<TooltipPlugin
data={alignedFrame}
config={uplotConfig}
mode={TooltipDisplayMode.Multi}
sync={sync}
timeZone={timeZone}
/>
</>
)}
{/* Renders annotation markers*/}
{showNewVizTooltips ? (
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={uplotConfig}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
/>
) : (
data.annotations && (
<AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} />
)
)}
{/* Enables annotations creation*/}
{!showNewVizTooltips ? (
enableAnnotationCreation ? (
<AnnotationEditorPlugin data={alignedFrame} timeZone={timeZone} config={uplotConfig}>
{({ startAnnotating }) => {
return (
<ContextMenuPlugin
data={alignedFrame}
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={alignedFrame}
config={uplotConfig}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[]}
/>
)
) : undefined}
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={uplotConfig}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
/>
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
{data.annotations && (
<ExemplarsPlugin config={uplotConfig} exemplars={data.annotations} timeZone={timeZone} />
)}
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
<ThresholdControlsPlugin
config={uplotConfig}
@ -410,8 +333,6 @@ export const CandlestickPanel = ({
onThresholdsChange={canEditThresholds ? onThresholdsChange : undefined}
/>
)}
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
</>
);
}}

View File

@ -1,222 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import uPlot from 'uplot';
import {
DataFrameType,
Field,
FieldType,
formattedValueToString,
getFieldDisplayName,
LinkModel,
TimeRange,
InterpolateFunction,
} from '@grafana/data';
import { HeatmapCellLayout } from '@grafana/schema';
import { LinkButton, VerticalGroup } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView';
import { getDataLinks } from '../status-history/utils';
import { HeatmapData } from './fields';
import { renderHistogram } from './renderHistogram';
import { HeatmapHoverEvent } from './utils';
type Props = {
data: HeatmapData;
hover: HeatmapHoverEvent;
showHistogram?: boolean;
timeRange: TimeRange;
replaceVars: InterpolateFunction;
};
export const HeatmapHoverView = (props: Props) => {
if (props.hover.seriesIdx === 2) {
return <DataHoverView data={props.data.exemplars} rowIndex={props.hover.dataIdx} header={'Exemplar'} />;
}
return <HeatmapHoverCell {...props} />;
};
const HeatmapHoverCell = ({ data, hover, showHistogram = false }: Props) => {
const index = hover.dataIdx;
const [isSparse] = useState(
() => data.heatmap?.meta?.type === DataFrameType.HeatmapCells && !isHeatmapCellsDense(data.heatmap)
);
const xField = data.heatmap?.fields[0];
const yField = data.heatmap?.fields[1];
const countField = data.heatmap?.fields[2];
const xDisp = (v: number) => {
if (xField?.display) {
return formattedValueToString(xField.display(v));
}
if (xField?.type === FieldType.time) {
const tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
const dashboard = getDashboardSrv().getCurrent();
return dashboard?.formatDate(v, tooltipTimeFormat);
}
return `${v}`;
};
const xVals = xField?.values;
const yVals = yField?.values;
const countVals = countField?.values;
// labeled buckets
const meta = readHeatmapRowsCustomMeta(data.heatmap);
const yDisp = yField?.display ? (v: string) => formattedValueToString(yField.display!(v)) : (v: string) => `${v}`;
const yValueIdx = index % (data.yBucketCount ?? 1);
const xValueIdx = Math.floor(index / (data.yBucketCount ?? 1));
let yBucketMin: string;
let yBucketMax: string;
let nonNumericOrdinalDisplay: string | undefined = undefined;
if (meta.yOrdinalDisplay) {
const yMinIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx - 1 : yValueIdx;
const yMaxIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx : yValueIdx + 1;
yBucketMin = yMinIdx < 0 ? meta.yMinDisplay! : `${meta.yOrdinalDisplay[yMinIdx]}`;
yBucketMax = `${meta.yOrdinalDisplay[yMaxIdx]}`;
// e.g. "pod-xyz123"
if (!meta.yOrdinalLabel || Number.isNaN(+meta.yOrdinalLabel[0])) {
nonNumericOrdinalDisplay = data.yLayout === HeatmapCellLayout.le ? yBucketMax : yBucketMin;
}
} else {
const value = yVals?.[yValueIdx];
if (data.yLayout === HeatmapCellLayout.le) {
yBucketMax = `${value}`;
if (data.yLog) {
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
let exp = logFn(value) - 1 / data.yLogSplit!;
yBucketMin = `${data.yLog ** exp}`;
} else {
yBucketMin = `${value - data.yBucketSize!}`;
}
} else {
yBucketMin = `${value}`;
if (data.yLog) {
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
let exp = logFn(value) + 1 / data.yLogSplit!;
yBucketMax = `${data.yLog ** exp}`;
} else {
yBucketMax = `${value + data.yBucketSize!}`;
}
}
}
let xBucketMin: number;
let xBucketMax: number;
if (data.xLayout === HeatmapCellLayout.le) {
xBucketMax = xVals?.[index];
xBucketMin = xBucketMax - data.xBucketSize!;
} else {
xBucketMin = xVals?.[index];
xBucketMax = xBucketMin + data.xBucketSize!;
}
const count = countVals?.[index];
let links: Array<LinkModel<Field>> = [];
const linksField = data.series?.fields[yValueIdx + 1];
if (linksField != null) {
const visible = !Boolean(linksField.config.custom?.hideFrom?.tooltip);
const hasLinks = (linksField.config.links?.length ?? 0) > 0;
if (visible && hasLinks) {
links = getDataLinks(linksField, xValueIdx);
}
}
let can = useRef<HTMLCanvasElement>(null);
let histCssWidth = 264;
let histCssHeight = 64;
let histCanWidth = Math.round(histCssWidth * uPlot.pxRatio);
let histCanHeight = Math.round(histCssHeight * uPlot.pxRatio);
useEffect(
() => {
if (showHistogram && xVals != null && countVals != null) {
renderHistogram(can, histCanWidth, histCanHeight, xVals, countVals, index, data.yBucketCount!);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[index]
);
if (isSparse) {
return (
<div>
<DataHoverView data={data.heatmap} rowIndex={index} />
</div>
);
}
const renderYBucket = () => {
if (nonNumericOrdinalDisplay) {
return <div>Name: {nonNumericOrdinalDisplay}</div>;
}
switch (data.yLayout) {
case HeatmapCellLayout.unknown:
return <div>{yDisp(yBucketMin)}</div>;
}
return (
<div>
Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
</div>
);
};
return (
<>
<div>
<div>{xDisp(xBucketMin)}</div>
{data.xLayout !== HeatmapCellLayout.unknown && <div>{xDisp(xBucketMax)}</div>}
</div>
{showHistogram && (
<canvas
width={histCanWidth}
height={histCanHeight}
ref={can}
style={{ width: histCssWidth + 'px', height: histCssHeight + 'px' }}
/>
)}
<div>
{renderYBucket()}
<div>
{getFieldDisplayName(countField!, data.heatmap)}: {data.display!(count)}
</div>
</div>
{links.length > 0 && (
<VerticalGroup>
{links.map((link, i) => (
<LinkButton
key={i}
icon={'external-link-alt'}
target={link.target}
href={link.href}
onClick={link.onClick}
fill="text"
style={{ width: '100%' }}
>
{link.title}
</LinkButton>
))}
</VerticalGroup>
)}
</>
);
};

View File

@ -1,36 +1,33 @@
import { css } from '@emotion/css';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { DashboardCursorSync, DataFrameType, GrafanaTheme2, PanelProps, TimeRange } from '@grafana/data';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { DashboardCursorSync, PanelProps, TimeRange } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { ScaleDistributionConfig } from '@grafana/schema';
import {
Portal,
ScaleDistribution,
TooltipPlugin2,
TooltipDisplayMode,
ZoomPlugin,
UPlotChart,
usePanelContext,
useStyles2,
useTheme2,
VizLayout,
VizTooltipContainer,
EventBusPlugin,
} from '@grafana/ui';
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { isTooltipScrollable } from '../timeseries/utils';
import { ExemplarModalHeader } from './ExemplarModalHeader';
import { HeatmapHoverView } from './HeatmapHoverViewOld';
import { HeatmapTooltip } from './HeatmapTooltip';
import { prepareHeatmapData } from './fields';
import { quantizeScheme } from './palettes';
import { Options } from './types';
import { HeatmapHoverEvent, prepConfig } from './utils';
import { prepConfig } from './utils';
interface HeatmapPanelProps extends PanelProps<Options> {}
@ -49,20 +46,8 @@ export const HeatmapPanel = ({
}: HeatmapPanelProps) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const { sync, canAddAnnotations } = usePanelContext();
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const syncAny = useCallback(
() => sync?.() !== DashboardCursorSync.Off,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const { sync, eventsScope, canAddAnnotations } = usePanelContext();
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
@ -111,35 +96,9 @@ export const HeatmapPanel = ({
return [null, info.heatmap?.fields.map((f) => f.values), [exemplarsXFacet, exemplarsYFacet]];
}, [info.heatmap, info.exemplars]);
const [hover, setHover] = useState<HeatmapHoverEvent | undefined>(undefined);
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
const isToolTipOpen = useRef<boolean>(false);
const onCloseToolTip = () => {
isToolTipOpen.current = false;
setShouldDisplayCloseButton(false);
onhover(null);
};
const onclick = () => {
isToolTipOpen.current = !isToolTipOpen.current;
// Linking into useState required to re-render tooltip
setShouldDisplayCloseButton(isToolTipOpen.current);
};
const onhover = useCallback(
(evt?: HeatmapHoverEvent | null) => {
setHover(evt ?? undefined);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[options, data.structureRev]
);
// ugh
const dataRef = useRef(info);
dataRef.current = info;
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
const builder = useMemo(() => {
const scaleConfig: ScaleDistributionConfig = dataRef.current?.heatmap?.fields[1].config?.custom?.scaleDistribution;
@ -147,12 +106,8 @@ export const HeatmapPanel = ({
return prepConfig({
dataRef,
theme,
onhover: !showNewVizTooltips ? onhover : null,
onclick: !showNewVizTooltips && options.tooltip.mode !== TooltipDisplayMode.None ? onclick : null,
isToolTipOpen,
timeZone,
getTimeRange: () => timeRangeRef.current,
sync,
cellGap: options.cellGap,
hideLE: options.filterValues?.le,
hideGE: options.filterValues?.ge,
@ -160,24 +115,26 @@ export const HeatmapPanel = ({
yAxisConfig: options.yAxis,
ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, timeZone, data.structureRev]);
}, [options, timeZone, data.structureRev, cursorSync]);
const renderLegend = () => {
if (!info.heatmap || !options.legend.show) {
return null;
}
let heatmapType = dataRef.current?.heatmap?.meta?.type;
let isSparseHeatmap = heatmapType === DataFrameType.HeatmapCells && !isHeatmapCellsDense(dataRef.current?.heatmap!);
let countFieldIdx = !isSparseHeatmap ? 2 : 3;
const countField = info.heatmap.fields[countFieldIdx];
let hoverValue: number | undefined = undefined;
// let heatmapType = dataRef.current?.heatmap?.meta?.type;
// let isSparseHeatmap = heatmapType === DataFrameType.HeatmapCells && !isHeatmapCellsDense(dataRef.current?.heatmap!);
// let countFieldIdx = !isSparseHeatmap ? 2 : 3;
// const countField = info.heatmap.fields[countFieldIdx];
// seriesIdx: 1 is heatmap layer; 2 is exemplar layer
if (hover && info.heatmap.fields && hover.seriesIdx === 1) {
hoverValue = countField.values[hover.dataIdx];
}
// if (hover && info.heatmap.fields && hover.seriesIdx === 1) {
// hoverValue = countField.values[hover.dataIdx];
// }
return (
<VizLayout.Legend placement="bottom" maxHeight="20%">
@ -212,90 +169,70 @@ export const HeatmapPanel = ({
<>
<VizLayout width={width} height={height} legend={renderLegend()}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart config={builder} data={facets as any} width={vizWidth} height={vizHeight}>
<EventBusPlugin config={builder} sync={syncAny} eventBus={eventBus} frame={info.series ?? info.heatmap} />
{!showNewVizTooltips && <ZoomPlugin config={builder} onZoom={onChangeTimeRange} />}
{showNewVizTooltips && (
<>
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={TooltipHoverMode.xyOne}
queryZoom={onChangeTimeRange}
syncTooltip={syncTooltip}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
return;
}
const annotate = () => {
let xVal = u.posToVal(u.cursor.left!, 'x');
setNewAnnotationRange({ from: xVal, to: xVal });
dismiss();
};
return (
<HeatmapTooltip
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
dataRef={dataRef}
isPinned={isPinned}
dismiss={dismiss}
showHistogram={options.tooltip.yHistogram}
showColorScale={options.tooltip.showColorScale}
panelData={data}
annotate={enableAnnotationCreation ? annotate : undefined}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
maxHeight={options.tooltip.maxHeight}
/>
)}
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
canvasRegionRendering={false}
/>
</>
<UPlotChart key={builder.uid} config={builder} data={facets as any} width={vizWidth} height={vizHeight}>
{cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={builder} eventBus={eventBus} frame={info.series ?? info.heatmap} />
)}
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
}
queryZoom={onChangeTimeRange}
syncMode={cursorSync}
syncScope={eventsScope}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
return;
}
const annotate = () => {
let xVal = u.posToVal(u.cursor.left!, 'x');
setNewAnnotationRange({ from: xVal, to: xVal });
dismiss();
};
return (
<HeatmapTooltip
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
dataRef={dataRef}
isPinned={isPinned}
dismiss={dismiss}
showHistogram={options.tooltip.yHistogram}
showColorScale={options.tooltip.showColorScale}
panelData={data}
annotate={enableAnnotationCreation ? annotate : undefined}
scrollable={isTooltipScrollable(options.tooltip)}
maxHeight={options.tooltip.maxHeight}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
/>
)}
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
canvasRegionRendering={false}
/>
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
</UPlotChart>
)}
</VizLayout>
{!showNewVizTooltips && (
<>
<Portal>
{hover && options.tooltip.mode !== TooltipDisplayMode.None && (
<VizTooltipContainer
position={{ x: hover.pageX, y: hover.pageY }}
offset={{ x: 10, y: 10 }}
allowPointerEvents={isToolTipOpen.current}
>
{shouldDisplayCloseButton && <ExemplarModalHeader onClick={onCloseToolTip} />}
<HeatmapHoverView
timeRange={timeRange}
data={info}
hover={hover}
showHistogram={options.tooltip.yHistogram}
replaceVars={replaceVariables}
/>
</VizTooltipContainer>
)}
</Portal>
</>
)}
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
const getStyles = () => ({
colorScaleWrapper: css({
marginLeft: '25px',
padding: '10px 0',

View File

@ -39,6 +39,8 @@ interface HeatmapTooltipProps {
dismiss: () => void;
panelData: PanelData;
annotate?: () => void;
scrollable?: boolean;
maxHeight?: number;
}
export const HeatmapTooltip = (props: HeatmapTooltipProps) => {
@ -64,6 +66,8 @@ const HeatmapHoverCell = ({
showColorScale = false,
mode,
annotate,
scrollable,
maxHeight,
}: HeatmapTooltipProps) => {
const index = dataIdxs[1]!;
const data = dataRef.current;
@ -349,7 +353,7 @@ const HeatmapHoverCell = ({
return (
<div className={styles.wrapper}>
<VizTooltipHeader item={headerItem} isPinned={isPinned} />
<VizTooltipContent items={contentItems} isPinned={isPinned}>
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} maxHeight={maxHeight}>
{customContent?.map((content, i) => (
<div key={i} style={{ padding: `${theme.spacing(1)} 0` }}>
{content}

View File

@ -428,11 +428,11 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
path: 'tooltip.maxHeight',
name: 'Max height',
category,
defaultValue: 600,
defaultValue: undefined,
settings: {
integer: true,
},
showIf: (options) => false, // config.featureToggles.newVizTooltips && options.tooltip?.mode !== TooltipDisplayMode.None,
showIf: (options) => options.tooltip?.mode !== TooltipDisplayMode.None,
});
category = ['Legend'];

View File

@ -1,8 +1,7 @@
import { MutableRefObject, RefObject } from 'react';
import { RefObject } from 'react';
import uPlot, { Cursor } from 'uplot';
import {
DashboardCursorSync,
DataFrameType,
formattedValueToString,
getValueFormat,
@ -41,25 +40,9 @@ interface PointsBuilderOpts {
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
}
export interface HeatmapHoverEvent {
seriesIdx: number;
dataIdx: number;
pageX: number;
pageY: number;
}
export interface HeatmapZoomEvent {
xMin: number;
xMax: number;
}
interface PrepConfigOpts {
dataRef: RefObject<HeatmapData>;
theme: GrafanaTheme2;
onhover?: null | ((evt?: HeatmapHoverEvent | null) => void);
onclick?: null | ((evt?: Object) => void);
onzoom?: null | ((evt: HeatmapZoomEvent) => void);
isToolTipOpen?: MutableRefObject<boolean>;
timeZone: string;
getTimeRange: () => TimeRange;
exemplarColor: string;
@ -68,28 +51,10 @@ interface PrepConfigOpts {
hideGE?: number;
yAxisConfig: YAxisConfig;
ySizeDivisor?: number;
sync?: () => DashboardCursorSync;
// Identifies the shared key for uPlot cursor sync
eventsScope?: string;
}
export function prepConfig(opts: PrepConfigOpts) {
const {
dataRef,
theme,
onhover,
onclick,
isToolTipOpen,
timeZone,
getTimeRange,
cellGap,
hideLE,
hideGE,
yAxisConfig,
ySizeDivisor,
sync,
eventsScope = '__global_',
} = opts;
const { dataRef, theme, timeZone, getTimeRange, cellGap, hideLE, hideGE, yAxisConfig, ySizeDivisor } = opts;
const xScaleKey = 'x';
let isTime = true;
@ -108,8 +73,6 @@ export function prepConfig(opts: PrepConfigOpts) {
let builder = new UPlotConfigBuilder(timeZone);
let rect: DOMRect;
builder.addHook('init', (u) => {
u.root.querySelectorAll<HTMLElement>('.u-cursor-pt').forEach((el) => {
Object.assign(el.style, {
@ -118,20 +81,6 @@ export function prepConfig(opts: PrepConfigOpts) {
background: 'transparent',
});
});
onclick &&
u.over.addEventListener(
'mouseup',
(e) => {
// @ts-ignore
let isDragging: boolean = u.cursor.drag._x || u.cursor.drag._y;
if (!isDragging) {
onclick(e);
}
},
true
);
});
if (isTime) {
@ -153,48 +102,6 @@ export function prepConfig(opts: PrepConfigOpts) {
});
}
// rect of .u-over (grid area)
builder.addHook('syncRect', (u, r) => {
rect = r;
});
let pendingOnleave: ReturnType<typeof setTimeout> | 0;
onhover &&
builder.addHook('setLegend', (u) => {
if (u.cursor.idxs != null) {
for (let i = 0; i < u.cursor.idxs.length; i++) {
const sel = u.cursor.idxs[i];
if (sel != null) {
const { left, top } = u.cursor;
if (!isToolTipOpen?.current) {
if (pendingOnleave) {
clearTimeout(pendingOnleave);
pendingOnleave = 0;
}
onhover({
seriesIdx: i,
dataIdx: sel,
pageX: rect.left + left!,
pageY: rect.top + top!,
});
}
return;
}
}
}
if (!isToolTipOpen?.current) {
// if tiles have gaps, reduce flashing / re-render (debounce onleave by 100ms)
if (!pendingOnleave) {
pendingOnleave = setTimeout(() => {
onhover(null);
}, 100);
}
}
});
builder.addHook('drawClear', (u) => {
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
@ -583,15 +490,6 @@ export function prepConfig(opts: PrepConfigOpts) {
},
};
if (sync && sync() !== DashboardCursorSync.Off) {
cursor.sync = {
key: eventsScope,
scales: [xScaleKey, null],
};
builder.setSync();
}
builder.setCursor(cursor);
return builder;

View File

@ -1,22 +1,9 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { CartesianCoords2D, DashboardCursorSync, DataFrame, FieldType, PanelProps } from '@grafana/data';
import { DashboardCursorSync, PanelProps } from '@grafana/data';
import { getLastStreamingDataFramePacket } from '@grafana/data/src/dataframe/StreamingDataFrame';
import { config } from '@grafana/runtime';
import {
EventBusPlugin,
Portal,
TooltipDisplayMode,
TooltipPlugin2,
UPlotConfigBuilder,
usePanelContext,
useTheme2,
VizTooltipContainer,
ZoomPlugin,
} from '@grafana/ui';
import { addTooltipSupport, HoverEvent } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport';
import { EventBusPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext, useTheme2 } from '@grafana/ui';
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart';
import {
prepareTimelineFields,
@ -24,18 +11,13 @@ import {
TimelineMode,
} from 'app/core/components/TimelineChart/utils';
import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin';
import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin';
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { getTimezones } from '../timeseries/utils';
import { getTimezones, isTooltipScrollable } from '../timeseries/utils';
import { StateTimelineTooltip } from './StateTimelineTooltip';
import { StateTimelineTooltip2 } from './StateTimelineTooltip2';
import { Options } from './panelcfg.gen';
const TOOLTIP_OFFSET = 10;
interface TimelinePanelProps extends PanelProps<Options> {}
/**
@ -53,43 +35,10 @@ export const StateTimelinePanel = ({
}: TimelinePanelProps) => {
const theme = useTheme2();
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const syncAny = useCallback(
() => sync?.() !== DashboardCursorSync.Off,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
const isToolTipOpen = useRef<boolean>(false);
const [hover, setHover] = useState<HoverEvent | undefined>(undefined);
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null);
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
const [isActive, setIsActive] = useState<boolean>(false);
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
const { sync, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext();
const onCloseToolTip = () => {
isToolTipOpen.current = false;
setCoords(null);
setShouldDisplayCloseButton(false);
};
const onUPlotClick = () => {
isToolTipOpen.current = !isToolTipOpen.current;
// Linking into useState required to re-render tooltip
setShouldDisplayCloseButton(isToolTipOpen.current);
};
const { sync, eventsScope, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext();
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
const { frames, warn } = useMemo(
() => prepareTimelineFields(data.series, options.mergeValues ?? true, timeRange, theme),
@ -103,65 +52,6 @@ export const StateTimelinePanel = ({
const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]);
const renderCustomTooltip = useCallback(
(alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null, onAnnotationAdd?: () => void) => {
const data = frames ?? [];
// Count value fields in the state-timeline-ready frame
const valueFieldsCount = data.reduce(
(acc, frame) => acc + frame.fields.filter((field) => field.type !== FieldType.time).length,
0
);
// Not caring about multi mode in StateTimeline
if (seriesIdx === null || datapointIdx === null) {
return null;
}
/**
* There could be a case when the tooltip shows a data from one of a multiple query and the other query finishes first
* from refreshing. This causes data to be out of sync. alignedData - 1 because Time field doesn't count.
* Render nothing in this case to prevent error.
* See https://github.com/grafana/support-escalations/issues/932
*/
if (alignedData.fields.length - 1 !== valueFieldsCount || !alignedData.fields[seriesIdx]) {
return null;
}
return (
<>
{shouldDisplayCloseButton && (
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<CloseButton
onClick={onCloseToolTip}
style={{
position: 'relative',
top: 'auto',
right: 'auto',
marginRight: 0,
}}
/>
</div>
)}
<StateTimelineTooltip
data={data}
alignedData={alignedData}
seriesIdx={seriesIdx}
datapointIdx={datapointIdx}
timeZone={timeZone}
onAnnotationAdd={onAnnotationAdd}
/>
</>
);
},
[timeZone, frames, shouldDisplayCloseButton]
);
if (!frames || warn) {
return (
<div className="panel-empty">
@ -177,7 +67,6 @@ export const StateTimelinePanel = ({
}
}
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
return (
<TimelineChart
@ -193,132 +82,67 @@ export const StateTimelinePanel = ({
mode={TimelineMode.Changes}
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
>
{(builder, alignedFrame) => {
if (oldConfig.current !== builder && !showNewVizTooltips) {
oldConfig.current = addTooltipSupport({
config: builder,
onUPlotClick,
setFocusedSeriesIdx,
setFocusedPointIdx,
setCoords,
setHover,
isToolTipOpen,
isActive,
setIsActive,
sync,
});
}
return (
<>
<EventBusPlugin config={builder} sync={syncAny} eventBus={eventBus} frame={alignedFrame} />
{showNewVizTooltips ? (
<>
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
}
queryZoom={onChangeTimeRange}
syncTooltip={syncTooltip}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
return;
}
const annotate = () => {
let xVal = u.posToVal(u.cursor.left!, 'x');
setNewAnnotationRange({ from: xVal, to: xVal });
dismiss();
};
return (
<StateTimelineTooltip2
frames={frames ?? []}
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}
annotate={enableAnnotationCreation ? annotate : undefined}
withDuration={true}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
maxHeight={options.tooltip.maxHeight}
/>
)}
{/* Renders annotations */}
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
canvasRegionRendering={false}
/>
</>
) : (
<>
<ZoomPlugin config={builder} onZoom={onChangeTimeRange} />
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
{/* Renders annotation markers*/}
{data.annotations && (
<AnnotationsPlugin annotations={data.annotations} config={builder} timeZone={timeZone} />
)}
{enableAnnotationCreation ? (
<AnnotationEditorPlugin data={alignedFrame} timeZone={timeZone} config={builder}>
{({ startAnnotating }) => {
if (options.tooltip.mode === TooltipDisplayMode.None) {
return null;
}
if (focusedPointIdx === null || (!isActive && sync && sync() === DashboardCursorSync.Crosshair)) {
return null;
}
return (
<Portal>
{hover && coords && focusedSeriesIdx && (
<VizTooltipContainer
position={{ x: coords.viewport.x, y: coords.viewport.y }}
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
allowPointerEvents={isToolTipOpen.current}
>
{renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx, () => {
startAnnotating({ coords: { plotCanvas: coords.canvas, viewport: coords.viewport } });
onCloseToolTip();
})}
</VizTooltipContainer>
)}
</Portal>
);
}}
</AnnotationEditorPlugin>
) : (
<Portal>
{options.tooltip.mode !== TooltipDisplayMode.None && hover && coords && (
<VizTooltipContainer
position={{ x: coords.viewport.x, y: coords.viewport.y }}
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
allowPointerEvents={isToolTipOpen.current}
>
{renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx)}
</VizTooltipContainer>
)}
</Portal>
)}
</>
{cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} />
)}
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
}
queryZoom={onChangeTimeRange}
syncMode={cursorSync}
syncScope={eventsScope}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
return;
}
const annotate = () => {
let xVal = u.posToVal(u.cursor.left!, 'x');
setNewAnnotationRange({ from: xVal, to: xVal });
dismiss();
};
return (
<StateTimelineTooltip2
frames={frames ?? []}
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}
annotate={enableAnnotationCreation ? annotate : undefined}
withDuration={true}
scrollable={isTooltipScrollable(options.tooltip)}
maxHeight={options.tooltip.maxHeight}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
/>
)}
{/* Renders annotations */}
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
canvasRegionRendering={false}
/>
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
</>
);
}}

View File

@ -1,129 +0,0 @@
import React from 'react';
import {
DataFrame,
FALLBACK_COLOR,
Field,
getDisplayProcessor,
getFieldDisplayName,
TimeZone,
LinkModel,
} from '@grafana/data';
import { MenuItem, SeriesTableRow, useTheme2 } from '@grafana/ui';
import { findNextStateIndex, fmtDuration } from 'app/core/components/TimelineChart/utils';
interface StateTimelineTooltipProps {
data: DataFrame[];
alignedData: DataFrame;
seriesIdx: number;
datapointIdx: number;
timeZone: TimeZone;
onAnnotationAdd?: () => void;
}
export const StateTimelineTooltip = ({
data,
alignedData,
seriesIdx,
datapointIdx,
timeZone,
onAnnotationAdd,
}: StateTimelineTooltipProps) => {
const theme = useTheme2();
if (!data || datapointIdx == null) {
return null;
}
const field = alignedData.fields[seriesIdx!];
const links: Array<LinkModel<Field>> = [];
const linkLookup = new Set<string>();
if (field.getLinks) {
const v = field.values[datapointIdx];
const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v };
field.getLinks({ calculatedValue: disp, valueRowIndex: datapointIdx }).forEach((link) => {
const key = `${link.title}/${link.href}`;
if (!linkLookup.has(key)) {
links.push(link);
linkLookup.add(key);
}
});
}
const xField = alignedData.fields[0];
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
const dataFrameFieldIndex = field.state?.origin;
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
const value = field.values[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[nextStateIdx!];
}
const stateTs = xField.values[datapointIdx!];
let toFragment = null;
let durationFragment = null;
if (nextStateTs) {
const duration = nextStateTs && fmtDuration(nextStateTs - stateTs);
durationFragment = (
<>
<br />
<strong>Duration:</strong> {duration}
</>
);
toFragment = (
<>
{' to'} <strong>{xFieldFmt(xField.values[nextStateIdx!]).text}</strong>
</>
);
}
return (
<div>
<div style={{ fontSize: theme.typography.bodySmall.fontSize }}>
{fieldDisplayName}
<br />
<SeriesTableRow label={display.text} color={display.color || FALLBACK_COLOR} isActive />
From <strong>{xFieldFmt(xField.values[datapointIdx!]).text}</strong>
{toFragment}
{durationFragment}
</div>
<div
style={{
margin: theme.spacing(1, -1, -1, -1),
borderTop: `1px solid ${theme.colors.border.weak}`,
}}
>
{onAnnotationAdd && <MenuItem label={'Add annotation'} icon={'comment-alt'} onClick={onAnnotationAdd} />}
{links.length > 0 &&
links.map((link, i) => (
<MenuItem
key={i}
icon={'external-link-alt'}
target={link.target}
label={link.title}
url={link.href}
onClick={link.onClick}
/>
))}
</div>
</div>
);
};
StateTimelineTooltip.displayName = 'StateTimelineTooltip';

View File

@ -30,6 +30,7 @@ export const StateTimelineTooltip2 = ({
annotate,
timeRange,
withDuration,
maxHeight,
}: StateTimelineTooltip2Props) => {
const styles = useStyles2(getStyles);
@ -83,7 +84,7 @@ export const StateTimelineTooltip2 = ({
return (
<div className={styles.wrapper}>
<VizTooltipHeader item={headerItem} isPinned={isPinned} />
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} />
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} maxHeight={maxHeight} />
{footer}
</div>
);

View File

@ -1,21 +1,8 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { CartesianCoords2D, DashboardCursorSync, DataFrame, FieldType, PanelProps } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
EventBusPlugin,
Portal,
TooltipDisplayMode,
TooltipPlugin2,
UPlotConfigBuilder,
usePanelContext,
useTheme2,
VizTooltipContainer,
ZoomPlugin,
} from '@grafana/ui';
import { addTooltipSupport, HoverEvent } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport';
import { DashboardCursorSync, PanelProps } from '@grafana/data';
import { EventBusPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext, useTheme2 } from '@grafana/ui';
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart';
import {
prepareTimelineFields,
@ -24,16 +11,12 @@ import {
} from 'app/core/components/TimelineChart/utils';
import { StateTimelineTooltip2 } from '../state-timeline/StateTimelineTooltip2';
import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin';
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { getTimezones } from '../timeseries/utils';
import { getTimezones, isTooltipScrollable } from '../timeseries/utils';
import { StatusHistoryTooltip } from './StatusHistoryTooltip';
import { Options } from './panelcfg.gen';
const TOOLTIP_OFFSET = 10;
interface TimelinePanelProps extends PanelProps<Options> {}
/**
@ -51,47 +34,13 @@ export const StatusHistoryPanel = ({
}: TimelinePanelProps) => {
const theme = useTheme2();
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const syncAny = useCallback(
() => sync?.() !== DashboardCursorSync.Off,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
const isToolTipOpen = useRef<boolean>(false);
const [hover, setHover] = useState<HoverEvent | undefined>(undefined);
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null);
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
const [isActive, setIsActive] = useState<boolean>(false);
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
const { sync, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext();
const { sync, eventsScope, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext();
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
const onCloseToolTip = () => {
isToolTipOpen.current = false;
setCoords(null);
setShouldDisplayCloseButton(false);
};
const onUPlotClick = () => {
isToolTipOpen.current = !isToolTipOpen.current;
// Linking into useState required to re-render tooltip
setShouldDisplayCloseButton(isToolTipOpen.current);
};
const { frames, warn } = useMemo(
() => prepareTimelineFields(data.series, false, timeRange, theme),
[data.series, timeRange, theme]
@ -102,89 +51,6 @@ export const StatusHistoryPanel = ({
[frames, options.legend, theme]
);
const renderCustomTooltip = useCallback(
(alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => {
const data = frames ?? [];
// Count value fields in the state-timeline-ready frame
const valueFieldsCount = data.reduce(
(acc, frame) => acc + frame.fields.filter((field) => field.type !== FieldType.time).length,
0
);
// Not caring about multi mode in StatusHistory
if (seriesIdx === null || datapointIdx === null) {
return null;
}
/**
* There could be a case when the tooltip shows a data from one of a multiple query and the other query finishes first
* from refreshing. This causes data to be out of sync. alignedData - 1 because Time field doesn't count.
* Render nothing in this case to prevent error.
* See https://github.com/grafana/support-escalations/issues/932
*/
if (alignedData.fields.length - 1 !== valueFieldsCount || !alignedData.fields[seriesIdx]) {
return null;
}
return (
<>
{shouldDisplayCloseButton && (
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<CloseButton
onClick={onCloseToolTip}
style={{
position: 'relative',
top: 'auto',
right: 'auto',
marginRight: 0,
}}
/>
</div>
)}
<StatusHistoryTooltip
data={data}
alignedData={alignedData}
seriesIdx={seriesIdx}
datapointIdx={datapointIdx}
timeZone={timeZone}
/>
</>
);
},
[timeZone, frames, shouldDisplayCloseButton]
);
const renderTooltip = (alignedFrame: DataFrame) => {
if (options.tooltip.mode === TooltipDisplayMode.None) {
return null;
}
if (focusedPointIdx === null || (!isActive && sync && sync() === DashboardCursorSync.Crosshair)) {
return null;
}
return (
<Portal>
{hover && coords && focusedSeriesIdx && (
<VizTooltipContainer
position={{ x: coords.viewport.x, y: coords.viewport.y }}
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
allowPointerEvents={isToolTipOpen.current}
>
{renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx)}
</VizTooltipContainer>
)}
</Portal>
);
};
const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]);
if (!frames || warn) {
@ -207,8 +73,6 @@ export const StatusHistoryPanel = ({
);
}
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
return (
<TimelineChart
theme={theme}
@ -223,92 +87,66 @@ export const StatusHistoryPanel = ({
mode={TimelineMode.Samples}
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
>
{(builder, alignedFrame) => {
if (oldConfig.current !== builder && !showNewVizTooltips) {
oldConfig.current = addTooltipSupport({
config: builder,
onUPlotClick,
setFocusedSeriesIdx,
setFocusedPointIdx,
setCoords,
setHover,
isToolTipOpen,
isActive,
setIsActive,
});
}
return (
<>
<EventBusPlugin config={builder} sync={syncAny} eventBus={eventBus} frame={alignedFrame} />
{showNewVizTooltips ? (
<>
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
}
queryZoom={onChangeTimeRange}
syncTooltip={syncTooltip}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
return;
}
const annotate = () => {
let xVal = u.posToVal(u.cursor.left!, 'x');
setNewAnnotationRange({ from: xVal, to: xVal });
dismiss();
};
return (
<StateTimelineTooltip2
frames={frames ?? []}
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}
annotate={enableAnnotationCreation ? annotate : undefined}
withDuration={false}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
maxHeight={options.tooltip.maxHeight}
/>
)}
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
canvasRegionRendering={false}
/>
</>
) : (
<>
<ZoomPlugin config={builder} onZoom={onChangeTimeRange} />
{renderTooltip(alignedFrame)}
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
{data.annotations && (
<AnnotationsPlugin
annotations={data.annotations}
config={builder}
timeZone={timeZone}
disableCanvasRendering={true}
/>
)}
</>
{cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} />
)}
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
}
queryZoom={onChangeTimeRange}
syncMode={cursorSync}
syncScope={eventsScope}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
return;
}
const annotate = () => {
let xVal = u.posToVal(u.cursor.left!, 'x');
setNewAnnotationRange({ from: xVal, to: xVal });
dismiss();
};
return (
<StateTimelineTooltip2
frames={frames ?? []}
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}
annotate={enableAnnotationCreation ? annotate : undefined}
withDuration={false}
scrollable={isTooltipScrollable(options.tooltip)}
maxHeight={options.tooltip.maxHeight}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
/>
)}
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
canvasRegionRendering={false}
/>
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
</>
);
}}

View File

@ -1,98 +0,0 @@
import React from 'react';
import {
DataFrame,
FALLBACK_COLOR,
Field,
getDisplayProcessor,
getFieldDisplayName,
TimeZone,
LinkModel,
} from '@grafana/data';
import { MenuItem, SeriesTableRow, useTheme2 } from '@grafana/ui';
interface StatusHistoryTooltipProps {
data: DataFrame[];
alignedData: DataFrame;
seriesIdx: number;
datapointIdx: number;
timeZone: TimeZone;
}
export const StatusHistoryTooltip = ({
data,
alignedData,
seriesIdx,
datapointIdx,
timeZone,
}: StatusHistoryTooltipProps) => {
const theme = useTheme2();
if (!data || datapointIdx == null) {
return null;
}
const field = alignedData.fields[seriesIdx!];
const links: Array<LinkModel<Field>> = [];
const linkLookup = new Set<string>();
if (field.getLinks) {
const v = field.values[datapointIdx];
const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v };
field.getLinks({ calculatedValue: disp, valueRowIndex: datapointIdx }).forEach((link) => {
const key = `${link.title}/${link.href}`;
if (!linkLookup.has(key)) {
links.push(link);
linkLookup.add(key);
}
});
}
const xField = alignedData.fields[0];
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
const dataFrameFieldIndex = field.state?.origin;
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
const value = field.values[datapointIdx!];
const display = fieldFmt(value);
const fieldDisplayName = dataFrameFieldIndex
? getFieldDisplayName(
data[dataFrameFieldIndex.frameIndex].fields[dataFrameFieldIndex.fieldIndex],
data[dataFrameFieldIndex.frameIndex],
data
)
: null;
return (
<div>
<div style={{ fontSize: theme.typography.bodySmall.fontSize }}>
<strong>{xFieldFmt(xField.values[datapointIdx]).text}</strong>
<br />
<SeriesTableRow label={display.text} color={display.color || FALLBACK_COLOR} isActive />
{fieldDisplayName}
</div>
{links.length > 0 && (
<div
style={{
margin: theme.spacing(1, -1, -1, -1),
borderTop: `1px solid ${theme.colors.border.weak}`,
}}
>
{links.map((link, i) => (
<MenuItem
key={i}
icon={'external-link-alt'}
target={link.target}
label={link.title}
url={link.href}
onClick={link.onClick}
/>
))}
</div>
)}
</div>
);
};
StatusHistoryTooltip.displayName = 'StatusHistoryTooltip';

View File

@ -1,26 +1,16 @@
import React, { useMemo, useState, useCallback } from 'react';
import React, { useMemo, useState } from 'react';
import { PanelProps, DataFrameType, DashboardCursorSync } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { TooltipDisplayMode, VizOrientation } from '@grafana/schema';
import {
EventBusPlugin,
KeyboardPlugin,
TooltipPlugin,
TooltipPlugin2,
usePanelContext,
ZoomPlugin,
} from '@grafana/ui';
import { EventBusPlugin, KeyboardPlugin, TooltipPlugin2, usePanelContext } from '@grafana/ui';
import { TimeRange2, 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';
import { AnnotationsPlugin2 } from './plugins/AnnotationsPlugin2';
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
import { ExemplarsPlugin, getVisibleLabels } from './plugins/ExemplarsPlugin';
import { OutsideRangePlugin } from './plugins/OutsideRangePlugin';
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
@ -43,6 +33,7 @@ export const TimeSeriesPanel = ({
}: TimeSeriesPanelProps) => {
const {
sync,
eventsScope,
canAddAnnotations,
onThresholdsChange,
canEditThresholds,
@ -67,22 +58,8 @@ export const TimeSeriesPanel = ({
}, [frames, id]);
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const syncAny = useCallback(
() => sync?.() !== DashboardCursorSync.Off,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
if (!frames || suggestions) {
return (
@ -98,14 +75,6 @@ export const TimeSeriesPanel = ({
);
}
// which annotation are we editing?
// are we adding a new annotation? is annotating?
// console.log(data.annotations);
// annotations plugin includes the editor and the renderer
// its annotation state is managed here for now
// tooltipplugin2 receives render with annotate range, callback should setstate here that gets passed to annotationsplugin as newAnnotaton or editAnnotation
return (
<TimeSeries
frames={frames}
@ -118,148 +87,85 @@ export const TimeSeriesPanel = ({
options={options}
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
>
{(uplotConfig, alignedFrame) => {
return (
<>
<KeyboardPlugin config={uplotConfig} />
<EventBusPlugin config={uplotConfig} sync={syncAny} eventBus={eventBus} frame={alignedFrame} />
{options.tooltip.mode === TooltipDisplayMode.None || (
<>
{showNewVizTooltips ? (
<TooltipPlugin2
config={uplotConfig}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
}
queryZoom={onChangeTimeRange}
clientZoom={true}
syncTooltip={syncTooltip}
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
return;
}
{cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={uplotConfig} eventBus={eventBus} frame={alignedFrame} />
)}
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={uplotConfig}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
}
queryZoom={onChangeTimeRange}
clientZoom={true}
syncMode={cursorSync}
syncScope={eventsScope}
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
return;
}
const annotate = () => {
let xVal = u.posToVal(u.cursor.left!, 'x');
const annotate = () => {
let xVal = u.posToVal(u.cursor.left!, 'x');
setNewAnnotationRange({ from: xVal, to: xVal });
dismiss();
};
setNewAnnotationRange({ from: xVal, to: xVal });
dismiss();
};
return (
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
<TimeSeriesTooltip
frames={frames}
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
annotate={enableAnnotationCreation ? annotate : undefined}
scrollable={isTooltipScrollable(options.tooltip)}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
maxHeight={options.tooltip.maxHeight}
/>
) : (
<>
<ZoomPlugin config={uplotConfig} onZoom={onChangeTimeRange} withZoomY={true} />
<TooltipPlugin
return (
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
<TimeSeriesTooltip
frames={frames}
data={alignedFrame}
config={uplotConfig}
mode={options.tooltip.mode}
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
sync={sync}
timeZone={timeZone}
isPinned={isPinned}
annotate={enableAnnotationCreation ? annotate : undefined}
scrollable={isTooltipScrollable(options.tooltip)}
maxHeight={options.tooltip.maxHeight}
/>
</>
);
}}
maxWidth={options.tooltip.maxWidth}
/>
)}
{!isVerticallyOriented && (
<>
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={uplotConfig}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
/>
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
{data.annotations && (
<ExemplarsPlugin
visibleSeries={getVisibleLabels(uplotConfig, frames)}
config={uplotConfig}
exemplars={data.annotations}
timeZone={timeZone}
/>
)}
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
<ThresholdControlsPlugin
config={uplotConfig}
fieldConfig={fieldConfig}
onThresholdsChange={canEditThresholds ? onThresholdsChange : undefined}
/>
)}
</>
)}
{/* Renders annotation markers*/}
{!isVerticallyOriented && showNewVizTooltips ? (
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={uplotConfig}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
/>
) : (
!isVerticallyOriented &&
data.annotations && (
<AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} />
)
)}
{/*Enables annotations creation*/}
{!showNewVizTooltips ? (
enableAnnotationCreation && !isVerticallyOriented ? (
<AnnotationEditorPlugin data={alignedFrame} timeZone={timeZone} config={uplotConfig}>
{({ startAnnotating }) => {
return (
<ContextMenuPlugin
data={alignedFrame}
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={alignedFrame}
frames={frames}
config={uplotConfig}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[]}
/>
)
) : undefined}
{data.annotations && !isVerticallyOriented && (
<ExemplarsPlugin
visibleSeries={getVisibleLabels(uplotConfig, frames)}
config={uplotConfig}
exemplars={data.annotations}
timeZone={timeZone}
/>
)}
{((canEditThresholds && onThresholdsChange) || showThresholds) && !isVerticallyOriented && (
<ThresholdControlsPlugin
config={uplotConfig}
fieldConfig={fieldConfig}
onThresholdsChange={canEditThresholds ? onThresholdsChange : undefined}
/>
)}
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
</>
);
}}

View File

@ -30,6 +30,7 @@ export interface TimeSeriesTooltipProps {
scrollable?: boolean;
annotate?: () => void;
maxHeight?: number;
}
export const TimeSeriesTooltip = ({
@ -42,6 +43,7 @@ export const TimeSeriesTooltip = ({
scrollable = false,
isPinned,
annotate,
maxHeight,
}: TimeSeriesTooltipProps) => {
const styles = useStyles2(getStyles);
@ -77,7 +79,7 @@ export const TimeSeriesTooltip = ({
return (
<div className={styles.wrapper}>
<VizTooltipHeader item={headerItem} isPinned={isPinned} />
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} />
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} maxHeight={maxHeight} />
{footer}
</div>
);

View File

@ -1,158 +0,0 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useMountedState } from 'react-use';
import uPlot from 'uplot';
import { CartesianCoords2D, DataFrame, TimeZone } from '@grafana/data';
import { PlotSelection, UPlotConfigBuilder } from '@grafana/ui';
import { AnnotationEditor } from './annotations/AnnotationEditor';
type StartAnnotatingFn = (props: {
// pixel coordinates of the clicked point on the uPlot canvas
coords: { viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null;
}) => void;
interface AnnotationEditorPluginProps {
data: DataFrame;
timeZone: TimeZone;
config: UPlotConfigBuilder;
children?: (props: { startAnnotating: StartAnnotatingFn }) => React.ReactNode;
}
/**
* @alpha
*/
export const AnnotationEditorPlugin = ({ data, timeZone, config, children }: AnnotationEditorPluginProps) => {
const plotInstance = useRef<uPlot>();
const [bbox, setBbox] = useState<DOMRect>();
const [isAddingAnnotation, setIsAddingAnnotation] = useState(false);
const [selection, setSelection] = useState<PlotSelection | null>(null);
const isMounted = useMountedState();
const clearSelection = useCallback(() => {
setSelection(null);
if (plotInstance.current) {
plotInstance.current.setSelect({ top: 0, left: 0, width: 0, height: 0 });
}
setIsAddingAnnotation(false);
}, [setIsAddingAnnotation, setSelection]);
useLayoutEffect(() => {
let annotating = false;
config.addHook('init', (u) => {
plotInstance.current = u;
// Wrap all setSelect hooks to prevent them from firing if user is annotating
const setSelectHooks = u.hooks.setSelect;
if (setSelectHooks) {
for (let i = 0; i < setSelectHooks.length; i++) {
const hook = setSelectHooks[i];
if (hook !== setSelect) {
setSelectHooks[i] = (...args) => {
!annotating && hook!(...args);
};
}
}
}
});
// cache uPlot plotting area bounding box
config.addHook('syncRect', (u, rect) => {
if (!isMounted()) {
return;
}
setBbox(rect);
});
const setSelect = (u: uPlot) => {
if (annotating) {
setIsAddingAnnotation(true);
setSelection({
min: u.posToVal(u.select.left, 'x'),
max: u.posToVal(u.select.left + u.select.width, 'x'),
bbox: {
left: u.select.left,
top: 0,
height: u.select.height,
width: u.select.width,
},
});
annotating = false;
}
};
config.addHook('setSelect', setSelect);
config.setCursor({
bind: {
mousedown: (u, targ, handler) => (e) => {
annotating = e.button === 0 && (e.metaKey || e.ctrlKey);
handler(e);
return null;
},
mouseup: (u, targ, handler) => (e) => {
// uPlot will not fire setSelect hooks for 0-width && 0-height selections
// so we force it to fire on single-point clicks by mutating left & height
if (annotating && u.select.width === 0) {
u.select.left = u.cursor.left!;
u.select.height = u.bbox.height / window.devicePixelRatio;
}
handler(e);
return null;
},
},
});
}, [config, setBbox, isMounted]);
const startAnnotating = useCallback<StartAnnotatingFn>(
({ coords }) => {
if (!plotInstance.current || !bbox || !coords) {
return;
}
const min = plotInstance.current.posToVal(coords.plotCanvas.x, 'x');
if (!min) {
return;
}
setSelection({
min,
max: min,
bbox: {
left: coords.plotCanvas.x,
top: 0,
height: bbox.height,
width: 0,
},
});
setIsAddingAnnotation(true);
},
[bbox]
);
return (
<>
{isAddingAnnotation && selection && bbox && (
<AnnotationEditor
selection={selection}
onDismiss={clearSelection}
onSave={clearSelection}
data={data}
timeZone={timeZone}
style={{
position: 'absolute',
top: `${bbox.top}px`,
left: `${bbox.left}px`,
width: `${bbox.width}px`,
height: `${bbox.height}px`,
}}
/>
)}
{children ? children({ startAnnotating }) : null}
</>
);
};

View File

@ -1,165 +0,0 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import uPlot from 'uplot';
import { colorManipulator, DataFrame, DataFrameFieldIndex, DataFrameView, TimeZone } from '@grafana/data';
import { EventsCanvas, UPlotConfigBuilder, useTheme2 } from '@grafana/ui';
import { AnnotationMarker } from './annotations/AnnotationMarker';
import { AnnotationsDataFrameViewDTO } from './types';
interface AnnotationsPluginProps {
config: UPlotConfigBuilder;
annotations: DataFrame[];
timeZone: TimeZone;
disableCanvasRendering?: boolean;
}
export const AnnotationsPlugin = ({
annotations,
timeZone,
config,
disableCanvasRendering = false,
}: AnnotationsPluginProps) => {
const theme = useTheme2();
const plotInstance = useRef<uPlot>();
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
// Update annotations views when new annotations came
useEffect(() => {
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
for (const frame of annotations) {
views.push(new DataFrameView(frame));
}
annotationsRef.current = views;
return () => {
// clear on unmount
annotationsRef.current = [];
};
}, [annotations]);
useLayoutEffect(() => {
config.addHook('init', (u) => {
plotInstance.current = u;
});
config.addHook('draw', (u) => {
// Render annotation lines on the canvas
/**
* We cannot rely on state value here, as it would require this effect to be dependent on the state value.
*/
if (!annotationsRef.current) {
return null;
}
const ctx = u.ctx;
if (!ctx) {
return;
}
ctx.save();
ctx.beginPath();
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
ctx.clip();
const renderLine = (x: number, color: string) => {
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = color;
ctx.setLineDash([5, 5]);
ctx.moveTo(x, u.bbox.top);
ctx.lineTo(x, u.bbox.top + u.bbox.height);
ctx.stroke();
ctx.closePath();
};
if (!disableCanvasRendering) {
for (let i = 0; i < annotationsRef.current.length; i++) {
const annotationsView = annotationsRef.current[i];
for (let j = 0; j < annotationsView.length; j++) {
const annotation = annotationsView.get(j);
if (!annotation.time) {
continue;
}
let x0 = u.valToPos(annotation.time, 'x', true);
const color = theme.visualization.getColorByName(annotation.color);
renderLine(x0, color);
if (annotation.isRegion && annotation.timeEnd) {
let x1 = u.valToPos(annotation.timeEnd, 'x', true);
renderLine(x1, color);
ctx.fillStyle = colorManipulator.alpha(color, 0.1);
ctx.rect(x0, u.bbox.top, x1 - x0, u.bbox.height);
ctx.fill();
}
}
}
}
ctx.restore();
return;
});
}, [config, theme, disableCanvasRendering]);
const mapAnnotationToXYCoords = useCallback((frame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
const annotation = view.get(dataFrameFieldIndex.fieldIndex);
if (!annotation.time || !plotInstance.current) {
return undefined;
}
let x = plotInstance.current.valToPos(annotation.time, 'x');
if (x < 0) {
x = 0;
}
return {
x,
y: plotInstance.current.bbox.height / window.devicePixelRatio + 4,
};
}, []);
const renderMarker = useCallback(
(frame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
let width = 0;
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
const annotation = view.get(dataFrameFieldIndex.fieldIndex);
const isRegionAnnotation = Boolean(annotation.isRegion);
if (isRegionAnnotation && plotInstance.current) {
let x0 = plotInstance.current.valToPos(annotation.time, 'x');
let x1 = plotInstance.current.valToPos(annotation.timeEnd, 'x');
// markers are rendered relatively to uPlot canvas overly, not caring about axes width
if (x0 < 0) {
x0 = 0;
}
if (x1 > plotInstance.current.bbox.width / window.devicePixelRatio) {
x1 = plotInstance.current.bbox.width / window.devicePixelRatio;
}
width = x1 - x0;
}
return <AnnotationMarker annotation={annotation} timeZone={timeZone} width={width} />;
},
[timeZone]
);
return (
<EventsCanvas
id="annotations"
config={config}
events={annotations}
renderEventMarker={renderMarker}
mapEventToXYCoords={mapAnnotationToXYCoords}
/>
);
};

View File

@ -187,13 +187,13 @@ export const AnnotationsPlugin2 = ({
for (let i = 0; i < vals.time.length; i++) {
let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR);
let left = plot.valToPos(vals.time[i], 'x');
let left = Math.round(plot.valToPos(vals.time[i], 'x')) || 0; // handles -0
let style: React.CSSProperties | null = null;
let className = '';
let isVisible = true;
if (vals.isRegion?.[i]) {
let right = plot.valToPos(vals.timeEnd?.[i], 'x');
let right = Math.round(plot.valToPos(vals.timeEnd?.[i], 'x')) || 0; // handles -0
isVisible = left < plot.rect.width && right > 0;
@ -205,7 +205,7 @@ export const AnnotationsPlugin2 = ({
className = styles.annoRegion;
}
} else {
isVisible = left > 0 && left <= plot.rect.width;
isVisible = left >= 0 && left <= plot.rect.width;
if (isVisible) {
style = { left, borderBottomColor: color };

View File

@ -1,230 +0,0 @@
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useClickAway } from 'react-use';
import { CartesianCoords2D, DataFrame, getFieldDisplayName, InterpolateFunction, TimeZone } from '@grafana/data';
import {
ContextMenu,
GraphContextMenuHeader,
MenuItemProps,
MenuItemsGroup,
MenuGroup,
MenuItem,
UPlotConfigBuilder,
} from '@grafana/ui';
type ContextMenuSelectionCoords = { viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D };
type ContextMenuSelectionPoint = { seriesIdx: number | null; dataIdx: number | null };
export interface ContextMenuItemClickPayload {
coords: ContextMenuSelectionCoords;
}
interface ContextMenuPluginProps {
data: DataFrame;
frames?: DataFrame[];
config: UPlotConfigBuilder;
defaultItems?: Array<MenuItemsGroup<ContextMenuItemClickPayload>>;
timeZone: TimeZone;
onOpen?: () => void;
onClose?: () => void;
replaceVariables?: InterpolateFunction;
}
export const ContextMenuPlugin = ({
data,
config,
onClose,
timeZone,
replaceVariables,
...otherProps
}: ContextMenuPluginProps) => {
const [coords, setCoords] = useState<ContextMenuSelectionCoords | null>(null);
const [point, setPoint] = useState<ContextMenuSelectionPoint | null>(null);
const [isOpen, setIsOpen] = useState(false);
useLayoutEffect(() => {
let seriesIdx: number | null = null;
config.addHook('init', (u) => {
u.over.addEventListener('click', (e) => {
// only open when have a focused point, and not for explicit annotations, zooms, etc.
if (seriesIdx != null && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
setCoords({
viewport: {
x: e.clientX,
y: e.clientY,
},
plotCanvas: {
x: e.clientX - u.rect.left,
y: e.clientY - u.rect.top,
},
});
setPoint({ seriesIdx, dataIdx: u.cursor.idxs![seriesIdx] });
setIsOpen(true);
}
});
});
config.addHook('setSeries', (u, _seriesIdx) => {
seriesIdx = _seriesIdx;
});
}, [config]);
const defaultItems = useMemo(() => {
return otherProps.defaultItems
? otherProps.defaultItems.map((i) => {
return {
...i,
items: i.items.map((j) => {
return {
...j,
onClick: (e: React.MouseEvent<HTMLElement>) => {
if (!coords) {
return;
}
j.onClick?.(e, { coords });
},
};
}),
};
})
: [];
}, [coords, otherProps.defaultItems]);
return (
<>
{isOpen && coords && (
<ContextMenuView
data={data}
frames={otherProps.frames}
defaultItems={defaultItems}
timeZone={timeZone}
selection={{ point, coords }}
replaceVariables={replaceVariables}
onClose={() => {
setPoint(null);
setIsOpen(false);
if (onClose) {
onClose();
}
}}
/>
)}
</>
);
};
interface ContextMenuViewProps {
data: DataFrame;
frames?: DataFrame[];
defaultItems?: MenuItemsGroup[];
timeZone: TimeZone;
onClose?: () => void;
selection: {
point?: { seriesIdx: number | null; dataIdx: number | null } | null;
coords: { plotCanvas: CartesianCoords2D; viewport: CartesianCoords2D };
};
replaceVariables?: InterpolateFunction;
}
export const ContextMenuView = ({
selection,
timeZone,
defaultItems,
replaceVariables,
data,
...otherProps
}: ContextMenuViewProps) => {
const ref = useRef(null);
const onClose = () => {
if (otherProps.onClose) {
otherProps.onClose();
}
};
useClickAway(ref, () => {
onClose();
});
const xField = data.fields[0];
if (!xField) {
return null;
}
const items = defaultItems ? [...defaultItems] : [];
let renderHeader: () => JSX.Element | null = () => null;
if (selection.point) {
const { seriesIdx, dataIdx } = selection.point;
const xFieldFmt = xField.display!;
if (seriesIdx && dataIdx !== null) {
const field = data.fields[seriesIdx];
const displayValue = field.display!(field.values[dataIdx]);
const hasLinks = field.config.links && field.config.links.length > 0;
if (hasLinks) {
if (field.getLinks) {
items.push({
items: field
.getLinks({
valueRowIndex: dataIdx,
})
.map<MenuItemProps>((link) => {
return {
label: link.title,
ariaLabel: link.title,
url: link.href,
target: link.target,
icon: link.target === '_self' ? 'link' : 'external-link-alt',
onClick: link.onClick,
};
}),
});
}
}
// eslint-disable-next-line react/display-name
renderHeader = () => (
<GraphContextMenuHeader
timestamp={xFieldFmt(xField.values[dataIdx]).text}
displayValue={displayValue}
seriesColor={displayValue.color!}
displayName={getFieldDisplayName(field, data, otherProps.frames)}
/>
);
}
}
const renderMenuGroupItems = () => {
return items?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={item.url}
url={item.url}
label={item.label}
target={item.target}
icon={item.icon}
active={item.active}
onClick={item.onClick}
/>
))}
</MenuGroup>
));
};
return (
<ContextMenu
renderMenuItems={renderMenuGroupItems}
renderHeader={renderHeader}
x={selection.coords.viewport.x}
y={selection.coords.viewport.y}
onClose={onClose}
/>
);
};

View File

@ -1,141 +0,0 @@
import { css, cx } from '@emotion/css';
import { autoUpdate, flip, shift, useDismiss, useFloating, useInteractions } from '@floating-ui/react';
import React, { HTMLAttributes } from 'react';
import { colorManipulator, DataFrame, getDisplayProcessor, GrafanaTheme2, TimeZone } from '@grafana/data';
import { PlotSelection, useStyles2, useTheme2, Portal, DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
import { getCommonAnnotationStyles } from '../styles';
import { AnnotationsDataFrameViewDTO } from '../types';
import { AnnotationEditorForm } from './AnnotationEditorForm';
interface AnnotationEditorProps extends HTMLAttributes<HTMLDivElement> {
data: DataFrame;
timeZone: TimeZone;
selection: PlotSelection;
onSave: () => void;
onDismiss: () => void;
annotation?: AnnotationsDataFrameViewDTO;
}
export const AnnotationEditor = ({
onDismiss,
onSave,
timeZone,
data,
selection,
annotation,
style,
}: AnnotationEditorProps) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const commonStyles = useStyles2(getCommonAnnotationStyles);
// the order of middleware is important!
const middleware = [
flip({
fallbackAxisSideDirection: 'end',
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
shift(),
];
const { context, refs, floatingStyles } = useFloating({
open: true,
placement: 'bottom',
onOpenChange: (open) => {
if (!open) {
onDismiss();
}
},
middleware,
whileElementsMounted: autoUpdate,
strategy: 'fixed',
});
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
let xField = data.fields[0];
if (!xField) {
return null;
}
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
const isRegionAnnotation = selection.min !== selection.max;
return (
<Portal>
<>
<div // div overlay matching uPlot canvas bbox
style={style}
>
<div // Annotation marker
className={cx(
css({
position: 'absolute',
top: selection.bbox.top,
left: selection.bbox.left,
width: selection.bbox.width,
height: selection.bbox.height,
}),
isRegionAnnotation ? styles.overlayRange(annotation) : styles.overlay(annotation)
)}
>
<div
ref={refs.setReference}
className={
isRegionAnnotation
? cx(commonStyles(annotation).markerBar, styles.markerBar)
: cx(commonStyles(annotation).markerTriangle, styles.markerTriangle)
}
{...getReferenceProps()}
/>
</div>
</div>
<AnnotationEditorForm
annotation={annotation || ({ time: selection.min, timeEnd: selection.max } as AnnotationsDataFrameViewDTO)}
timeFormatter={(v) => xFieldFmt(v).text}
onSave={onSave}
onDismiss={onDismiss}
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
/>
</>
</Portal>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
overlay: (annotation?: AnnotationsDataFrameViewDTO) => {
const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR);
return css({
borderLeft: `1px dashed ${color}`,
});
},
overlayRange: (annotation?: AnnotationsDataFrameViewDTO) => {
const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR);
return css({
background: colorManipulator.alpha(color, 0.1),
borderLeft: `1px dashed ${color}`,
borderRight: `1px dashed ${color}`,
});
},
markerTriangle: css({
top: `calc(100% + 2px)`,
left: '-4px',
position: 'absolute',
}),
markerBar: css({
top: '100%',
left: 0,
position: 'absolute',
}),
};
};

View File

@ -1,182 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { HTMLAttributes, useRef } from 'react';
import { Controller } from 'react-hook-form';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import useClickAway from 'react-use/lib/useClickAway';
import { AnnotationEventUIModel, GrafanaTheme2 } from '@grafana/data';
import { Button, Field, Stack, TextArea, usePanelContext, useStyles2 } from '@grafana/ui';
import { Form } from 'app/core/components/Form/Form';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { getAnnotationTags } from 'app/features/annotations/api';
import { AnnotationsDataFrameViewDTO } from '../types';
interface AnnotationEditFormDTO {
description: string;
tags: string[];
}
interface AnnotationEditorFormProps extends HTMLAttributes<HTMLDivElement> {
annotation: AnnotationsDataFrameViewDTO;
timeFormatter: (v: number) => string;
onSave: () => void;
onDismiss: () => void;
}
export const AnnotationEditorForm = React.forwardRef<HTMLDivElement, AnnotationEditorFormProps>(
({ annotation, onSave, onDismiss, timeFormatter, className, ...otherProps }, ref) => {
const styles = useStyles2(getStyles);
const panelContext = usePanelContext();
const clickAwayRef = useRef(null);
useClickAway(clickAwayRef, () => {
onDismiss();
});
const [createAnnotationState, createAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => {
const result = await panelContext.onAnnotationCreate!(event);
if (onSave) {
onSave();
}
return result;
});
const [updateAnnotationState, updateAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => {
const result = await panelContext.onAnnotationUpdate!(event);
if (onSave) {
onSave();
}
return result;
});
const isUpdatingAnnotation = annotation.id !== undefined;
const isRegionAnnotation = annotation.time !== annotation.timeEnd;
const operation = isUpdatingAnnotation ? updateAnnotation : createAnnotation;
const stateIndicator = isUpdatingAnnotation ? updateAnnotationState : createAnnotationState;
const ts = isRegionAnnotation
? `${timeFormatter(annotation.time)} - ${timeFormatter(annotation.timeEnd)}`
: timeFormatter(annotation.time);
const onSubmit = ({ tags, description }: AnnotationEditFormDTO) => {
operation({
id: annotation.id,
tags,
description,
from: Math.round(annotation.time!),
to: Math.round(annotation.timeEnd!),
});
};
const form = (
<div // Annotation editor
ref={ref}
className={cx(styles.editor, className)}
{...otherProps}
>
<div className={styles.header}>
<Stack justifyContent={'space-between'} alignItems={'center'}>
<div className={styles.title}>Add annotation</div>
<div className={styles.ts}>{ts}</div>
</Stack>
</div>
<div className={styles.editorForm}>
<Form<AnnotationEditFormDTO>
onSubmit={onSubmit}
defaultValues={{ description: annotation?.text, tags: annotation?.tags || [] }}
>
{({ register, errors, control }) => {
return (
<>
<Field label={'Description'} invalid={!!errors.description} error={errors?.description?.message}>
<TextArea
{...register('description', {
required: 'Annotation description is required',
})}
/>
</Field>
<Field label={'Tags'}>
<Controller
control={control}
name="tags"
render={({ field: { ref, onChange, ...field } }) => {
return (
<TagFilter
allowCustomValue
placeholder="Add tags"
onChange={onChange}
tagOptions={getAnnotationTags}
tags={field.value}
/>
);
}}
/>
</Field>
<Stack justifyContent={'flex-end'}>
<Button size={'sm'} variant="secondary" onClick={onDismiss} fill="outline">
Cancel
</Button>
<Button size={'sm'} type={'submit'} disabled={stateIndicator?.loading}>
{stateIndicator?.loading ? 'Saving' : 'Save'}
</Button>
</Stack>
</>
);
}}
</Form>
</div>
</div>
);
return (
<>
<div className={styles.backdrop} />
<div ref={clickAwayRef}>{form}</div>
</>
);
}
);
AnnotationEditorForm.displayName = 'AnnotationEditorForm';
const getStyles = (theme: GrafanaTheme2) => {
return {
backdrop: css`
label: backdrop;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
z-index: ${theme.zIndex.navbarFixed};
`,
editorContainer: css`
position: absolute;
top: calc(100% + 10px);
transform: translate3d(-50%, 0, 0);
`,
editor: css`
background: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z3};
z-index: ${theme.zIndex.dropdown};
border: 1px solid ${theme.colors.border.weak};
border-radius: ${theme.shape.radius.default};
width: 460px;
`,
editorForm: css`
padding: ${theme.spacing(1)};
`,
header: css`
border-bottom: 1px solid ${theme.colors.border.weak};
padding: ${theme.spacing(1.5, 1)};
`,
title: css`
font-weight: ${theme.typography.fontWeightMedium};
`,
ts: css`
font-size: ${theme.typography.bodySmall.fontSize};
color: ${theme.colors.text.secondary};
`,
};
};

View File

@ -1,167 +0,0 @@
import { css } from '@emotion/css';
import {
autoUpdate,
flip,
safePolygon,
shift,
useDismiss,
useFloating,
useHover,
useInteractions,
} from '@floating-ui/react';
import React, { HTMLAttributes, useCallback, useState } from 'react';
import { GrafanaTheme2, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Portal, useStyles2, usePanelContext } from '@grafana/ui';
import { getTooltipContainerStyles } from '@grafana/ui/src/themes/mixins';
import { getCommonAnnotationStyles } from '../styles';
import { AnnotationsDataFrameViewDTO } from '../types';
import { AnnotationEditorForm } from './AnnotationEditorForm';
import { AnnotationTooltip } from './AnnotationTooltip';
interface Props extends HTMLAttributes<HTMLDivElement> {
timeZone: TimeZone;
annotation: AnnotationsDataFrameViewDTO;
width: number;
}
const MIN_REGION_ANNOTATION_WIDTH = 6;
export function AnnotationMarker({ annotation, timeZone, width }: Props) {
const { canEditAnnotations, canDeleteAnnotations, ...panelCtx } = usePanelContext();
const commonStyles = useStyles2(getCommonAnnotationStyles);
const styles = useStyles2(getStyles);
const [isOpen, setIsOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
// the order of middleware is important!
const middleware = [
flip({
fallbackAxisSideDirection: 'end',
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
shift(),
];
const { context, refs, floatingStyles } = useFloating({
open: isOpen,
placement: 'bottom',
onOpenChange: setIsOpen,
middleware,
whileElementsMounted: autoUpdate,
strategy: 'fixed',
});
const hover = useHover(context, {
handleClose: safePolygon(),
});
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, hover]);
const onAnnotationEdit = useCallback(() => {
setIsEditing(true);
setIsOpen(false);
}, [setIsEditing, setIsOpen]);
const onAnnotationDelete = useCallback(() => {
if (panelCtx.onAnnotationDelete) {
panelCtx.onAnnotationDelete(annotation.id);
}
}, [annotation, panelCtx]);
const timeFormatter = useCallback(
(value: number) => {
return dateTimeFormat(value, {
format: systemDateFormats.fullDate,
timeZone,
});
},
[timeZone]
);
const renderTooltip = useCallback(() => {
return (
<AnnotationTooltip
annotation={annotation}
timeFormatter={timeFormatter}
onEdit={onAnnotationEdit}
onDelete={onAnnotationDelete}
canEdit={canEditAnnotations ? canEditAnnotations(annotation.dashboardUID) : false}
canDelete={canDeleteAnnotations ? canDeleteAnnotations(annotation.dashboardUID) : false}
/>
);
}, [canEditAnnotations, canDeleteAnnotations, onAnnotationDelete, onAnnotationEdit, timeFormatter, annotation]);
const isRegionAnnotation = Boolean(annotation.isRegion) && width > MIN_REGION_ANNOTATION_WIDTH;
let left = `${width / 2}px`;
let marker = (
<div
className={commonStyles(annotation).markerTriangle}
style={{ left, position: 'relative', transform: 'translate3d(-100%,-50%, 0)' }}
/>
);
if (isRegionAnnotation) {
marker = (
<div
className={commonStyles(annotation).markerBar}
style={{ width: `${width}px`, transform: 'translate3d(0,-50%, 0)' }}
/>
);
}
return (
<>
<div
ref={refs.setReference}
className={!isRegionAnnotation ? styles.markerWrapper : undefined}
data-testid={selectors.pages.Dashboard.Annotations.marker}
{...getReferenceProps()}
>
{marker}
</div>
{isOpen && (
<Portal>
<div className={styles.tooltip} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
{renderTooltip()}
</div>
</Portal>
)}
{isEditing && (
<Portal>
<AnnotationEditorForm
onDismiss={() => setIsEditing(false)}
onSave={() => setIsEditing(false)}
timeFormatter={timeFormatter}
annotation={annotation}
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
/>
</Portal>
)}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
markerWrapper: css({
label: 'markerWrapper',
padding: theme.spacing(0, 0.5, 0.5, 0.5),
}),
tooltip: css({
...getTooltipContainerStyles(theme),
padding: 0,
}),
};
};

View File

@ -1,152 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, textUtil } from '@grafana/data';
import { HorizontalGroup, IconButton, Tag, useStyles2 } from '@grafana/ui';
import alertDef from 'app/features/alerting/state/alertDef';
import { AnnotationsDataFrameViewDTO } from '../types';
interface AnnotationTooltipProps {
annotation: AnnotationsDataFrameViewDTO;
timeFormatter: (v: number) => string;
canEdit: boolean;
canDelete: boolean;
onEdit: () => void;
onDelete: () => void;
}
export const AnnotationTooltip = ({
annotation,
timeFormatter,
canEdit,
canDelete,
onEdit,
onDelete,
}: AnnotationTooltipProps) => {
const styles = useStyles2(getStyles);
const time = timeFormatter(annotation.time);
const timeEnd = timeFormatter(annotation.timeEnd);
let text = annotation.text;
const tags = annotation.tags;
let alertText = '';
let avatar;
let editControls;
let state: React.ReactNode | null = null;
const ts = <span className={styles.time}>{Boolean(annotation.isRegion) ? `${time} - ${timeEnd}` : time}</span>;
if (annotation.login && annotation.avatarUrl) {
avatar = <img className={styles.avatar} alt="Annotation avatar" src={annotation.avatarUrl} />;
}
if (annotation.alertId !== undefined && annotation.newState) {
const stateModel = alertDef.getStateDisplayModel(annotation.newState);
state = (
<div className={styles.alertState}>
<i className={stateModel.stateClass}>{stateModel.text}</i>
</div>
);
alertText = alertDef.getAlertAnnotationInfo(annotation);
} else if (annotation.title) {
text = annotation.title + '<br />' + (typeof text === 'string' ? text : '');
}
if (canEdit || canDelete) {
editControls = (
<div className={styles.editControls}>
{canEdit && <IconButton name={'pen'} size={'sm'} onClick={onEdit} tooltip="Edit" />}
{canDelete && <IconButton name={'trash-alt'} size={'sm'} onClick={onDelete} tooltip="Delete" />}
</div>
);
}
return (
<div className={styles.wrapper}>
<div className={styles.header}>
<HorizontalGroup justify={'space-between'} align={'center'} spacing={'md'}>
<div className={styles.meta}>
<span>
{avatar}
{state}
</span>
{ts}
</div>
{editControls}
</HorizontalGroup>
</div>
<div className={styles.body}>
{text && <div dangerouslySetInnerHTML={{ __html: textUtil.sanitize(text) }} />}
{alertText}
<>
<HorizontalGroup spacing="xs" wrap>
{tags?.map((t, i) => <Tag name={t} key={`${t}-${i}`} />)}
</HorizontalGroup>
</>
</div>
</div>
);
};
AnnotationTooltip.displayName = 'AnnotationTooltip';
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css`
max-width: 400px;
`,
commentWrapper: css`
margin-top: 10px;
border-top: 2px solid #2d2b34;
height: 30vh;
overflow-y: scroll;
padding: 0 3px;
`,
header: css`
padding: ${theme.spacing(0.5, 1)};
border-bottom: 1px solid ${theme.colors.border.weak};
font-size: ${theme.typography.bodySmall.fontSize};
display: flex;
`,
meta: css`
display: flex;
justify-content: space-between;
`,
editControls: css`
display: flex;
align-items: center;
> :last-child {
margin-right: 0;
}
`,
avatar: css`
border-radius: ${theme.shape.radius.circle};
width: 16px;
height: 16px;
margin-right: ${theme.spacing(1)};
`,
alertState: css`
padding-right: ${theme.spacing(1)};
font-weight: ${theme.typography.fontWeightMedium};
`,
time: css`
color: ${theme.colors.text.secondary};
font-weight: normal;
display: inline-block;
position: relative;
top: 1px;
`,
body: css`
padding: ${theme.spacing(1)};
a {
color: ${theme.colors.text.link};
&:hover {
text-decoration: underline;
}
}
`,
};
};

View File

@ -5,6 +5,7 @@ import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { TimeZone } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
@ -80,6 +81,7 @@ export const AnnotationMarker2 = ({
style={style!}
onMouseEnter={() => state !== STATE_EDITING && setState(STATE_HOVERED)}
onMouseLeave={() => state !== STATE_EDITING && setState(STATE_DEFAULT)}
data-testid={selectors.pages.Dashboard.Annotations.marker}
>
{contents &&
createPortal(

View File

@ -1,27 +0,0 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
import { AnnotationsDataFrameViewDTO } from './types';
export const getCommonAnnotationStyles = (theme: GrafanaTheme2) => {
return (annotation?: AnnotationsDataFrameViewDTO) => {
const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR);
return {
markerTriangle: css`
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid ${color};
`,
markerBar: css`
display: block;
width: calc(100%);
height: 5px;
background: ${color};
`,
};
};
};

View File

@ -14,6 +14,8 @@ import { nullToValue } from '@grafana/data/src/transformations/transformers/null
import { GraphFieldConfig, LineInterpolation, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
import { buildScaleKey } from '@grafana/ui/src/components/uPlot/internal';
import { HeatmapTooltip } from '../heatmap/panelcfg.gen';
type ScaleKey = string;
// this will re-enumerate all enum fields on the same scale to create one ordinal progression
@ -263,6 +265,6 @@ export function getTimezones(timezones: string[] | undefined, defaultTimezone: s
return timezones.map((v) => (v?.length ? v : defaultTimezone));
}
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions) => {
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions | HeatmapTooltip) => {
return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null;
};

View File

@ -3,7 +3,7 @@ import React, { useMemo } from 'react';
import { DataFrame, FieldMatcherID, fieldMatchers, FieldType, PanelProps, TimeRange } from '@grafana/data';
import { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { KeyboardPlugin, TooltipDisplayMode, usePanelContext, TooltipPlugin, TooltipPlugin2 } from '@grafana/ui';
import { KeyboardPlugin, TooltipDisplayMode, usePanelContext, TooltipPlugin2 } from '@grafana/ui';
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { XYFieldMatchers } from 'app/core/components/GraphNG/types';
import { preparePlotFrame } from 'app/core/components/GraphNG/utils';
@ -26,8 +26,6 @@ export const TrendPanel = ({
replaceVariables,
id,
}: PanelProps<Options>) => {
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
const { dataLinkPostProcessor } = usePanelContext();
// Need to fallback to first number field if no xField is set in options otherwise panel crashes 😬
const trendXFieldName =
@ -117,41 +115,28 @@ export const TrendPanel = ({
<>
<KeyboardPlugin config={uPlotConfig} />
{options.tooltip.mode !== TooltipDisplayMode.None && (
<>
{showNewVizTooltips ? (
<TooltipPlugin2
config={uPlotConfig}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
}
render={(u, dataIdxs, seriesIdx, isPinned = false) => {
return (
<TimeSeriesTooltip
frames={info.frames!}
seriesFrame={alignedDataFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
scrollable={isTooltipScrollable(options.tooltip)}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
maxHeight={options.tooltip.maxHeight}
/>
) : (
<TooltipPlugin
frames={info.frames!}
data={alignedDataFrame}
config={uPlotConfig}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
timeZone={timeZone}
/>
)}
</>
<TooltipPlugin2
config={uPlotConfig}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
}
render={(u, dataIdxs, seriesIdx, isPinned = false) => {
return (
<TimeSeriesTooltip
frames={info.frames!}
seriesFrame={alignedDataFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
scrollable={isTooltipScrollable(options.tooltip)}
maxHeight={options.tooltip.maxHeight}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
/>
)}
</>
);

View File

@ -1,184 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataFrame, Field, formattedValueToString, getFieldDisplayName, GrafanaTheme2, LinkModel } from '@grafana/data';
import { LinkButton, useStyles2, VerticalGroup, VizTooltipOptions } from '@grafana/ui';
import { findField } from 'app/features/dimensions';
import { getTitleFromHref } from 'app/features/explore/utils/links';
import { ScatterSeriesConfig, SeriesMapping } from './panelcfg.gen';
import { ScatterSeries } from './types';
interface YValue {
name: string;
val: number;
field: Field;
color: string;
}
interface ExtraFacets {
colorFacetFieldName: string;
sizeFacetFieldName: string;
colorFacetValue: number;
sizeFacetValue: number;
}
export interface Props {
allSeries: ScatterSeries[];
data: DataFrame[]; // source data
manualSeriesConfigs: ScatterSeriesConfig[] | undefined;
rowIndex?: number; // the hover row
seriesMapping: SeriesMapping;
hoveredPointIndex: number; // the hovered point
options: VizTooltipOptions;
}
export const TooltipView = ({
allSeries,
data,
manualSeriesConfigs,
seriesMapping,
rowIndex,
hoveredPointIndex,
options,
}: Props) => {
const style = useStyles2(getStyles);
if (!allSeries || rowIndex == null) {
return null;
}
const series = allSeries[hoveredPointIndex];
const frame = series.frame(data);
const xField = series.x(frame);
const yField = series.y(frame);
let links: LinkModel[] | undefined = undefined;
if (yField.getLinks) {
const v = yField.values[rowIndex];
const disp = yField.display ? yField.display(v) : { text: `${v}`, numeric: +v };
links = yField.getLinks({ calculatedValue: disp, valueRowIndex: rowIndex }).map((linkModel) => {
if (!linkModel.title) {
linkModel.title = getTitleFromHref(linkModel.href);
}
return linkModel;
});
}
let extraFields: Field[] = frame.fields.filter((f) => f !== xField && f !== yField);
let yValue: YValue | null = null;
let extraFacets: ExtraFacets | null = null;
if (seriesMapping === SeriesMapping.Manual && manualSeriesConfigs) {
const colorFacetFieldName = manualSeriesConfigs[hoveredPointIndex]?.pointColor?.field ?? '';
const sizeFacetFieldName = manualSeriesConfigs[hoveredPointIndex]?.pointSize?.field ?? '';
const colorFacet = colorFacetFieldName ? findField(frame, colorFacetFieldName) : undefined;
const sizeFacet = sizeFacetFieldName ? findField(frame, sizeFacetFieldName) : undefined;
extraFacets = {
colorFacetFieldName,
sizeFacetFieldName,
colorFacetValue: colorFacet?.values[rowIndex],
sizeFacetValue: sizeFacet?.values[rowIndex],
};
extraFields = extraFields.filter((f) => f !== colorFacet && f !== sizeFacet);
}
yValue = {
name: getFieldDisplayName(yField, frame),
val: yField.values[rowIndex],
field: yField,
color: series.pointColor(frame) as string,
};
return (
<>
<table className={style.infoWrap}>
<tr>
<th colSpan={2} style={{ backgroundColor: yValue.color }}></th>
</tr>
<tbody>
<tr>
<th>{getFieldDisplayName(xField, frame)}</th>
<td>{fmt(xField, xField.values[rowIndex])}</td>
</tr>
<tr>
<th>{yValue.name}:</th>
<td>{fmt(yValue.field, yValue.val)}</td>
</tr>
{extraFacets !== null && extraFacets.colorFacetFieldName && (
<tr>
<th>{extraFacets.colorFacetFieldName}:</th>
<td>{extraFacets.colorFacetValue}</td>
</tr>
)}
{extraFacets !== null && extraFacets.sizeFacetFieldName && (
<tr>
<th>{extraFacets.sizeFacetFieldName}:</th>
<td>{extraFacets.sizeFacetValue}</td>
</tr>
)}
{extraFields.map((field, i) => (
<tr key={i}>
<th>{getFieldDisplayName(field, frame)}:</th>
<td>{fmt(field, field.values[rowIndex])}</td>
</tr>
))}
{links && links.length > 0 && (
<tr>
<td colSpan={2}>
<VerticalGroup>
{links.map((link, i) => (
<LinkButton
key={i}
icon={'external-link-alt'}
target={link.target}
href={link.href}
onClick={link.onClick}
fill="text"
style={{ width: '100%' }}
>
{link.title}
</LinkButton>
))}
</VerticalGroup>
</td>
</tr>
)}
</tbody>
</table>
</>
);
};
function fmt(field: Field, val: number): string {
if (field.display) {
return formattedValueToString(field.display(val));
}
return `${val}`;
}
const getStyles = (theme: GrafanaTheme2) => ({
infoWrap: css({
padding: '8px',
width: '100%',
th: {
fontWeight: theme.typography.fontWeightMedium,
padding: theme.spacing(0.25, 2),
},
}),
highlight: css({
background: theme.colors.action.hover,
}),
xVal: css({
fontWeight: theme.typography.fontWeightBold,
}),
icon: css({
marginRight: theme.spacing(1),
verticalAlign: 'middle',
}),
});

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { usePrevious } from 'react-use';
import {
@ -14,7 +14,6 @@ import {
import { alpha } from '@grafana/data/src/themes/colorManipulator';
import { config } from '@grafana/runtime';
import {
Portal,
TooltipDisplayMode,
TooltipPlugin2,
UPlotChart,
@ -22,61 +21,29 @@ import {
VizLayout,
VizLegend,
VizLegendItem,
VizTooltipContainer,
} from '@grafana/ui';
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { FacetedData } from '@grafana/ui/src/components/uPlot/types';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { TooltipView } from './TooltipView';
import { XYChartTooltip } from './XYChartTooltip';
import { Options, SeriesMapping } from './panelcfg.gen';
import { prepData, prepScatter, ScatterPanelInfo } from './scatter';
import { ScatterHoverEvent, ScatterSeries } from './types';
import { ScatterSeries } from './types';
type Props = PanelProps<Options>;
const TOOLTIP_OFFSET = 10;
export const XYChartPanel = (props: Props) => {
const [error, setError] = useState<string | undefined>();
const [series, setSeries] = useState<ScatterSeries[]>([]);
const [builder, setBuilder] = useState<UPlotConfigBuilder | undefined>();
const [facets, setFacets] = useState<FacetedData | undefined>();
const [hover, setHover] = useState<ScatterHoverEvent | undefined>();
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
const isToolTipOpen = useRef<boolean>(false);
const oldOptions = usePrevious(props.options);
const oldData = usePrevious(props.data);
const onCloseToolTip = () => {
isToolTipOpen.current = false;
setShouldDisplayCloseButton(false);
scatterHoverCallback(undefined);
};
const onUPlotClick = () => {
isToolTipOpen.current = !isToolTipOpen.current;
// Linking into useState required to re-render tooltip
setShouldDisplayCloseButton(isToolTipOpen.current);
};
const scatterHoverCallback = (hover?: ScatterHoverEvent) => {
setHover(hover);
};
const initSeries = useCallback(() => {
const getData = () => props.data.series;
const info: ScatterPanelInfo = prepScatter(
props.options,
getData,
config.theme2,
showNewVizTooltips ? null : scatterHoverCallback,
showNewVizTooltips ? null : onUPlotClick,
showNewVizTooltips ? null : isToolTipOpen
);
const info: ScatterPanelInfo = prepScatter(props.options, getData, config.theme2);
if (info.error) {
setError(info.error);
@ -86,7 +53,7 @@ export const XYChartPanel = (props: Props) => {
setFacets(() => prepData(info, props.data.series));
setError(undefined);
}
}, [props.data.series, props.options, showNewVizTooltips]);
}, [props.data.series, props.options]);
const initFacets = useCallback(() => {
setFacets(() => prepData({ error, series }, props.data.series));
@ -223,7 +190,7 @@ export const XYChartPanel = (props: Props) => {
<VizLayout width={props.width} height={props.height} legend={renderLegend()}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart config={builder} data={facets} width={vizWidth} height={vizHeight}>
{showNewVizTooltips && props.options.tooltip.mode !== TooltipDisplayMode.None && (
{props.options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={TooltipHoverMode.xyOne}
@ -241,52 +208,11 @@ export const XYChartPanel = (props: Props) => {
);
}}
maxWidth={props.options.tooltip.maxWidth}
maxHeight={props.options.tooltip.maxHeight}
/>
)}
</UPlotChart>
)}
</VizLayout>
{!showNewVizTooltips && (
<Portal>
{hover && props.options.tooltip.mode !== TooltipDisplayMode.None && (
<VizTooltipContainer
position={{ x: hover.pageX, y: hover.pageY }}
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
allowPointerEvents={isToolTipOpen.current}
>
{shouldDisplayCloseButton && (
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<CloseButton
onClick={onCloseToolTip}
style={{
position: 'relative',
top: 'auto',
right: 'auto',
marginRight: 0,
}}
/>
</div>
)}
<TooltipView
options={props.options.tooltip}
allSeries={series}
manualSeriesConfigs={props.options.series}
seriesMapping={props.options.seriesMapping!}
rowIndex={hover.xIndex}
hoveredPointIndex={hover.scatterIndex}
data={props.data.series}
/>
</VizTooltipContainer>
)}
</Portal>
)}
</>
);
};

View File

@ -1,4 +1,3 @@
import { MutableRefObject } from 'react';
import uPlot from 'uplot';
import {
@ -31,7 +30,7 @@ import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
import { DEFAULT_POINT_SIZE } from './config';
import { isGraphable } from './dims';
import { FieldConfig, defaultFieldConfig, Options, ScatterShow } from './panelcfg.gen';
import { DimensionValues, ScatterHoverCallback, ScatterSeries } from './types';
import { DimensionValues, ScatterSeries } from './types';
export interface ScatterPanelInfo {
error?: string;
@ -42,20 +41,13 @@ export interface ScatterPanelInfo {
/**
* This is called when options or structure rev changes
*/
export function prepScatter(
options: Options,
getData: () => DataFrame[],
theme: GrafanaTheme2,
ttip: null | ScatterHoverCallback,
onUPlotClick: null | ((evt?: Object) => void),
isToolTipOpen: null | MutableRefObject<boolean>
): ScatterPanelInfo {
export function prepScatter(options: Options, getData: () => DataFrame[], theme: GrafanaTheme2): ScatterPanelInfo {
let series: ScatterSeries[];
let builder: UPlotConfigBuilder;
try {
series = prepSeries(options, getData());
builder = prepConfig(getData, series, theme, ttip, onUPlotClick, isToolTipOpen);
builder = prepConfig(getData, series, theme);
} catch (e) {
let errorMsg = 'Unknown error in prepScatter';
if (typeof e === 'string') {
@ -298,14 +290,7 @@ interface DrawBubblesOpts {
};
}
const prepConfig = (
getData: () => DataFrame[],
scatterSeries: ScatterSeries[],
theme: GrafanaTheme2,
ttip: null | ScatterHoverCallback,
onUPlotClick: null | ((evt?: Object) => void),
isToolTipOpen: null | MutableRefObject<boolean>
) => {
const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], theme: GrafanaTheme2) => {
let qt: Quadtree;
let hRect: Rect | null;
@ -524,73 +509,7 @@ const prepConfig = (
},
});
const clearPopupIfOpened = () => {
if (isToolTipOpen?.current) {
if (ttip) {
ttip(undefined);
}
if (onUPlotClick) {
onUPlotClick();
}
}
};
let ref_parent: HTMLElement | null = null;
// clip hover points/bubbles to plotting area
builder.addHook('init', (u, r) => {
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
if (!showNewVizTooltips) {
u.over.style.overflow = 'hidden';
}
ref_parent = u.root.parentElement;
if (onUPlotClick) {
ref_parent?.addEventListener('click', onUPlotClick);
}
});
builder.addHook('destroy', (u) => {
if (onUPlotClick) {
ref_parent?.removeEventListener('click', onUPlotClick);
clearPopupIfOpened();
}
});
let rect: DOMRect;
// rect of .u-over (grid area)
builder.addHook('syncRect', (u, r) => {
rect = r;
});
if (ttip) {
builder.addHook('setLegend', (u) => {
if (u.cursor.idxs != null) {
for (let i = 0; i < u.cursor.idxs.length; i++) {
const sel = u.cursor.idxs[i];
if (sel != null && !isToolTipOpen?.current) {
ttip({
scatterIndex: i - 1,
xIndex: sel,
pageX: rect.left + u.cursor.left!,
pageY: rect.top + u.cursor.top!,
});
return; // only show the first one
}
}
}
if (!isToolTipOpen?.current) {
ttip(undefined);
}
});
}
builder.addHook('drawClear', (u) => {
clearPopupIfOpened();
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
qt.clear();

View File

@ -7,15 +7,6 @@ import { VizLegendItem } from '@grafana/ui';
*/
export type DimensionValues<T> = (frame: DataFrame, from?: number) => T | T[];
export interface ScatterHoverEvent {
scatterIndex: number;
xIndex: number;
pageX: number;
pageY: number;
}
export type ScatterHoverCallback = (evt?: ScatterHoverEvent) => void;
// Using field where we will need formatting/scale/axis info
// Use raw or DimensionValues when the values can be used directly
export interface ScatterSeries {
@ -50,14 +41,3 @@ export interface ScatterSeries {
};
};
}
export interface ExtraFacets {
colorFacetFieldName: string;
sizeFacetFieldName: string;
colorFacetValue: number;
sizeFacetValue: number;
}
export interface DataFilterBySeries {
frame: number;
}

View File

@ -112,7 +112,6 @@ export const XYChartPanel2 = (props: Props2) => {
);
}}
maxWidth={props.options.tooltip.maxWidth}
maxHeight={props.options.tooltip.maxHeight}
/>
)}
</UPlotChart>