mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Chore: Restore some removed UI components to reduce BC surface area (#85546)
This commit is contained in:
parent
9c0f9f6ba4
commit
130d561924
@ -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';
|
||||
|
@ -74,6 +74,13 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
|
||||
"stroke": [Function],
|
||||
"width": [Function],
|
||||
},
|
||||
"sync": {
|
||||
"key": "__global_",
|
||||
"scales": [
|
||||
"x",
|
||||
null,
|
||||
],
|
||||
},
|
||||
},
|
||||
"focus": {
|
||||
"alpha": 1,
|
||||
|
@ -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();
|
||||
|
@ -19,6 +19,7 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
|
||||
declare context: React.ContextType<typeof PanelContextRoot>;
|
||||
|
||||
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<TimeSeriesProps> {
|
||||
theme,
|
||||
timeZones: Array.isArray(timeZone) ? timeZone : [timeZone],
|
||||
getTimeRange,
|
||||
sync,
|
||||
allFrames,
|
||||
renderers,
|
||||
tweakScale,
|
||||
|
@ -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<string, number> {
|
||||
export 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;
|
||||
|
@ -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<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 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 = (
|
||||
<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',
|
||||
}),
|
||||
});
|
127
packages/grafana-ui/src/graveyard/uPlot/plugins/ZoomPlugin.tsx
Normal file
127
packages/grafana-ui/src/graveyard/uPlot/plugins/ZoomPlugin.tsx
Normal file
@ -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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user