diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index ca48dd03bc3..274cea8e082 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -311,3 +311,6 @@ export { TimeSeries } from '../graveyard/TimeSeries/TimeSeries'; export { useGraphNGContext } from '../graveyard/GraphNG/hooks'; export { preparePlotFrame, buildScaleKey } from '../graveyard/GraphNG/utils'; export { type GraphNGLegendEvent } from '../graveyard/GraphNG/types'; + +export { ZoomPlugin } from '../graveyard/uPlot/plugins/ZoomPlugin'; +export { TooltipPlugin } from '../graveyard/uPlot/plugins/TooltipPlugin'; diff --git a/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap index 51fa99f228d..2aff27a97fa 100644 --- a/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap @@ -74,6 +74,13 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "stroke": [Function], "width": [Function], }, + "sync": { + "key": "__global_", + "scales": [ + "x", + null, + ], + }, }, "focus": { "alpha": 1, diff --git a/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts b/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts index c8f17094a27..7475d4aec7c 100644 --- a/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts +++ b/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts @@ -1,5 +1,6 @@ import { createTheme, + DashboardCursorSync, DataFrame, DefaultTimeZone, FieldColorModeId, @@ -213,6 +214,7 @@ describe('GraphNG utils', () => { theme: createTheme(), timeZones: [DefaultTimeZone], getTimeRange: getDefaultTimeRange, + sync: () => DashboardCursorSync.Tooltip, allFrames: [frame!], }).getConfig(); expect(result).toMatchSnapshot(); diff --git a/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx index 5f6e2070b2b..19f6706a300 100644 --- a/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx +++ b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx @@ -19,6 +19,7 @@ export class UnthemedTimeSeries extends Component { declare context: React.ContextType; prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { + const { sync } = this.context; const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props; return preparePlotConfigBuilder({ @@ -26,6 +27,7 @@ export class UnthemedTimeSeries extends Component { theme, timeZones: Array.isArray(timeZone) ? timeZone : [timeZone], getTimeRange, + sync, allFrames, renderers, tweakScale, diff --git a/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts index f7600b81d92..bb270e9c672 100644 --- a/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts @@ -2,6 +2,7 @@ import { isNumber } from 'lodash'; import uPlot from 'uplot'; import { + DashboardCursorSync, DataFrame, FieldConfig, FieldType, @@ -70,16 +71,21 @@ const defaultConfig: GraphFieldConfig = { axisPlacement: AxisPlacement.Auto, }; -export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ +export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ + sync?: () => DashboardCursorSync; +}> = ({ frame, theme, timeZones, getTimeRange, + sync, allFrames, renderers, tweakScale = (opts) => opts, tweakAxis = (opts) => opts, }) => { + const eventsScope = '__global_'; + const builder = new UPlotConfigBuilder(timeZones[0]); let alignedFrame: DataFrame; @@ -98,6 +104,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ } const xScaleKey = 'x'; + let yScaleKey = ''; const xFieldAxisPlacement = xField.config.custom?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden; @@ -258,6 +265,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ ) ); + if (!yScaleKey) { + yScaleKey = scaleKey; + } + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { let axisColor: uPlot.Axis.Stroke | undefined; @@ -533,6 +544,8 @@ 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; @@ -585,12 +598,19 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ }, }; + if (sync && sync() !== DashboardCursorSync.Off) { + cursor.sync = { + key: eventsScope, + scales: [xScaleKey, null], + }; + } + builder.setCursor(cursor); return builder; }; -function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map { +export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map { const originNames = new Map(); frame.fields.forEach((field, i) => { const origin = field.state?.origin; diff --git a/packages/grafana-ui/src/graveyard/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/graveyard/uPlot/plugins/TooltipPlugin.tsx new file mode 100644 index 00000000000..74d0bc4941c --- /dev/null +++ b/packages/grafana-ui/src/graveyard/uPlot/plugins/TooltipPlugin.tsx @@ -0,0 +1,297 @@ +import { css } from '@emotion/css'; +import React, { 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 { Portal, SeriesTable, SeriesTableRowProps, UPlotConfigBuilder, VizTooltipContainer } from '../../../components'; +import { findMidPointYPosition } from '../../../components/uPlot/utils'; +import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; + +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(); + const theme = useTheme2(); + const [focusedSeriesIdx, setFocusedSeriesIdx] = useState(null); + const [focusedPointIdx, setFocusedPointIdx] = useState(null); + const [focusedPointIdxs, setFocusedPointIdxs] = useState>([]); + const [coords, setCoords] = useState(null); + const [isActive, setIsActive] = useState(false); + const isMounted = useMountedState(); + let parentWithFocus: HTMLElement | null = null; + + const style = useStyles2(getStyles); + + // 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); + + // eslint-disable-next-line react-hooks/exhaustive-deps + 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'); + } + }); + + 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 = ( + + ); + } + + 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 = ; + } + } else { + tooltip = renderTooltip(otherProps.data, focusedSeriesIdx, focusedPointIdx); + } + + return ( + + {tooltip && coords && ( + + {tooltip} + + )} + + ); +}; + +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', + }), +}); diff --git a/packages/grafana-ui/src/graveyard/uPlot/plugins/ZoomPlugin.tsx b/packages/grafana-ui/src/graveyard/uPlot/plugins/ZoomPlugin.tsx new file mode 100644 index 00000000000..72bbf76b17c --- /dev/null +++ b/packages/grafana-ui/src/graveyard/uPlot/plugins/ZoomPlugin.tsx @@ -0,0 +1,127 @@ +import { useLayoutEffect } from 'react'; + +import { UPlotConfigBuilder } from '../../../components'; + +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, onZoom, withZoomY]); + + return null; +};