mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Tooltips: Add TooltipPlugin2 (#75708)
This commit is contained in:
parent
c31ddce0b6
commit
27aa1c466a
@ -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,
|
||||||
|
@ -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),
|
||||||
|
});
|
@ -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}`,
|
||||||
|
}),
|
||||||
|
});
|
@ -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';
|
||||||
|
Loading…
Reference in New Issue
Block a user