Tooltips: Add TooltipPlugin2 (#75708)

This commit is contained in:
Leon Sorokin 2023-09-28 20:53:26 -05:00 committed by GitHub
parent c31ddce0b6
commit 27aa1c466a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 332 additions and 1 deletions

View File

@ -205,7 +205,8 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> {
next: () => { next: () => {
const u = this.plotInstance?.current; const u = this.plotInstance?.current;
if (u) { // @ts-ignore
if (u && !u.cursor._lock) {
u.setCursor({ u.setCursor({
left: -10, left: -10,
top: -10, top: -10,

View File

@ -0,0 +1,35 @@
// mostly copy/pasted from: public/app/core/components/CloseButton/CloseButton.tsx
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton } from '../../../components/IconButton/IconButton';
import { useStyles2 } from '../../../themes/ThemeContext';
type Props = {
onClick: () => void;
'aria-label'?: string;
style?: React.CSSProperties;
};
export const CloseButton = ({ onClick, 'aria-label': ariaLabel, style }: Props) => {
const styles = useStyles2(getStyles);
return (
<IconButton
aria-label={ariaLabel ?? 'Close'}
className={styles}
name="times"
onClick={onClick}
style={style}
tooltip="Close"
/>
);
};
const getStyles = (theme: GrafanaTheme2) =>
css({
position: 'absolute',
right: theme.spacing(0.5),
top: theme.spacing(1),
});

View File

@ -0,0 +1,294 @@
import { css } from '@emotion/css';
import React, { useLayoutEffect, useRef, useReducer, CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import uPlot from 'uplot';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes/ThemeContext';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { CloseButton } from './CloseButton';
interface TooltipPlugin2Props {
config: UPlotConfigBuilder;
// or via .children() render prop callback?
render: (
u: uPlot,
dataIdxs: Array<number | null>,
seriesIdx: number | null,
isPinned: boolean,
dismiss: () => void
) => React.ReactNode;
}
interface TooltipContainerState {
plot?: uPlot | null;
style: Partial<CSSProperties>;
isHovering: boolean;
isPinned: boolean;
dismiss: () => void;
contents?: React.ReactNode;
}
interface TooltipContainerSize {
observer: ResizeObserver;
width: number;
height: number;
}
function mergeState(prevState: TooltipContainerState, nextState: Partial<TooltipContainerState>) {
return {
...prevState,
...nextState,
style: {
...prevState.style,
...nextState.style,
},
};
}
const INITIAL_STATE: TooltipContainerState = {
style: { transform: '', pointerEvents: 'none' },
isHovering: false,
isPinned: false,
contents: null,
plot: null,
dismiss: () => {},
};
/**
* @alpha
*/
export const TooltipPlugin2 = ({ config, render }: TooltipPlugin2Props) => {
const domRef = useRef<HTMLDivElement>(null);
const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, INITIAL_STATE);
const sizeRef = useRef<TooltipContainerSize>();
const className = useStyles2(getStyles).tooltipWrapper;
useLayoutEffect(() => {
sizeRef.current = {
width: 0,
height: 0,
observer: new ResizeObserver((entries) => {
let size = sizeRef.current!;
for (const entry of entries) {
if (entry.borderBoxSize?.length > 0) {
size.width = entry.borderBoxSize[0].inlineSize;
size.height = entry.borderBoxSize[0].blockSize;
} else {
size.width = entry.contentRect.width;
size.height = entry.contentRect.height;
}
}
}),
};
let _plot = plot;
let _isHovering = isHovering;
let _isPinned = isPinned;
let _style = style;
let offsetX = 0;
let offsetY = 0;
let htmlEl = document.documentElement;
let winWidth = htmlEl.clientWidth - 16;
let winHeight = htmlEl.clientHeight - 16;
window.addEventListener('resize', (e) => {
winWidth = htmlEl.clientWidth - 5;
winHeight = htmlEl.clientHeight - 5;
});
let closestSeriesIdx: number | null = null;
let pendingRender = false;
const scheduleRender = () => {
if (!pendingRender) {
// defer unrender for 100ms to reduce flickering in small gaps
if (!_isHovering) {
setTimeout(_render, 100);
} else {
queueMicrotask(_render);
}
pendingRender = true;
}
};
const _render = () => {
pendingRender = false;
let state: TooltipContainerState = {
style: _style,
isPinned: _isPinned,
isHovering: _isHovering,
contents: _isHovering ? render(_plot!, _plot!.cursor.idxs!, closestSeriesIdx, _isPinned, dismiss) : null,
dismiss,
};
setState(state);
};
const dismiss = () => {
_isPinned = false;
_isHovering = false;
_style = { pointerEvents: 'none' };
// @ts-ignore
_plot!.cursor._lock = _isPinned;
scheduleRender();
};
config.addHook('init', (u) => {
setState({ plot: (_plot = u) });
// TODO: use cursor.lock & and mousedown/mouseup here (to prevent unlocking)
u.over.addEventListener('click', (e) => {
if (_isHovering) {
if (e.target === u.over) {
_isPinned = !_isPinned;
_style = { pointerEvents: _isPinned ? 'all' : 'none' };
scheduleRender();
}
// @ts-ignore
u.cursor._lock = _isPinned;
// hack to trigger cursor to new position after unlock
// (should not be necessary after using the cursor.lock API)
if (!_isPinned) {
u.setCursor({ left: e.clientX - u.rect.left, top: e.clientY - u.rect.top });
_isHovering = false;
}
}
});
});
// fires on data value hovers/unhovers (before setSeries)
config.addHook('setLegend', (u) => {
let _isHoveringNow = _plot!.cursor.idxs!.some((v) => v != null);
if (_isHoveringNow) {
// create
if (!_isHovering) {
_isHovering = true;
}
} else {
// destroy...TODO: debounce this
if (_isHovering) {
_isHovering = false;
}
}
scheduleRender();
});
// fires on series focus/proximity changes
// e.g. to highlight the hovered/closest series
// TODO: we only need this for multi/all mode?
config.addHook('setSeries', (u, seriesIdx) => {
// don't jiggle focused series styling when there's only one series
const isMultiSeries = u.series.length > 2;
if (isMultiSeries && closestSeriesIdx !== seriesIdx) {
closestSeriesIdx = seriesIdx;
scheduleRender();
}
});
// fires on mousemoves
config.addHook('setCursor', (u) => {
let { left = -10, top = -10 } = u.cursor;
if (left >= 0 || top >= 0) {
let { width, height } = sizeRef.current!;
let clientX = u.rect.left + left;
let clientY = u.rect.top + top;
if (offsetY) {
if (clientY + height < winHeight || clientY - height < 0) {
offsetY = 0;
} else if (offsetY !== -height) {
offsetY = -height;
}
} else {
if (clientY + height > winHeight && clientY - height >= 0) {
offsetY = -height;
}
}
if (offsetX) {
if (clientX + width < winWidth || clientX - width < 0) {
offsetX = 0;
} else if (offsetX !== -width) {
offsetX = -width;
}
} else {
if (clientX + width > winWidth && clientX - width >= 0) {
offsetX = -width;
}
}
const shiftX = offsetX !== 0 ? 'translateX(-100%)' : '';
const shiftY = offsetY !== 0 ? 'translateY(-100%)' : '';
// TODO: to a transition only when switching sides
// transition: transform 100ms;
const transform = `${shiftX} translateX(${left}px) ${shiftY} translateY(${top}px)`;
if (_isHovering) {
if (domRef.current != null) {
domRef.current.style.transform = transform;
} else {
_style.transform = transform;
scheduleRender();
}
}
}
});
}, [config]);
useLayoutEffect(() => {
const size = sizeRef.current!;
if (domRef.current != null) {
size.observer.observe(domRef.current);
}
}, [domRef.current]);
if (plot && isHovering) {
return createPortal(
<div className={className} style={style} ref={domRef}>
{isPinned && <CloseButton onClick={dismiss} style={{ top: '16px' }} />}
{contents}
</div>,
plot.over
);
}
return null;
};
const getStyles = (theme: GrafanaTheme2) => ({
tooltipWrapper: css({
top: 0,
left: 0,
zIndex: theme.zIndex.tooltip,
padding: '8px',
whiteSpace: 'pre',
borderRadius: theme.shape.radius.default,
position: 'absolute',
background: theme.colors.background.secondary,
boxShadow: `0 4px 8px ${theme.colors.background.primary}`,
}),
});

View File

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