Chore: Restore some removed UI components to reduce BC surface area (#85546)

This commit is contained in:
Leon Sorokin 2024-04-04 04:31:28 -05:00 committed by GitHub
parent 9c0f9f6ba4
commit 130d561924
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 460 additions and 2 deletions

View File

@ -311,3 +311,6 @@ export { TimeSeries } from '../graveyard/TimeSeries/TimeSeries';
export { useGraphNGContext } from '../graveyard/GraphNG/hooks'; export { useGraphNGContext } from '../graveyard/GraphNG/hooks';
export { preparePlotFrame, buildScaleKey } from '../graveyard/GraphNG/utils'; export { preparePlotFrame, buildScaleKey } from '../graveyard/GraphNG/utils';
export { type GraphNGLegendEvent } from '../graveyard/GraphNG/types'; export { type GraphNGLegendEvent } from '../graveyard/GraphNG/types';
export { ZoomPlugin } from '../graveyard/uPlot/plugins/ZoomPlugin';
export { TooltipPlugin } from '../graveyard/uPlot/plugins/TooltipPlugin';

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
}),
});

View 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;
};