mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-03 08:33:26 +00:00
feat: added a new tooltip plugin
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
.tooltip-plugin-container {
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1070;
|
||||
white-space: pre;
|
||||
border-radius: 4px;
|
||||
position: fixed;
|
||||
overflow: auto;
|
||||
|
||||
&.pinned {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
413
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin.tsx
Normal file
413
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import cx from 'classnames';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type {
|
||||
TooltipControllerContext,
|
||||
TooltipControllerState,
|
||||
} from './tooltipController';
|
||||
import {
|
||||
createInitialControllerState,
|
||||
createSetCursorHandler,
|
||||
createSetLegendHandler,
|
||||
createSetSeriesHandler,
|
||||
isScrollEventInPlot,
|
||||
updatePlotVisibility,
|
||||
updateWindowSize,
|
||||
} from './tooltipController';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
TooltipLayoutInfo,
|
||||
TooltipPluginProps,
|
||||
TooltipViewState,
|
||||
} from './types';
|
||||
|
||||
import './TooltipPlugin.styles.scss';
|
||||
|
||||
const INTERACTIVE_CONTAINER = '.tooltip-plugin-container';
|
||||
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
|
||||
// the plot – this avoids flicker when moving between nearby points.
|
||||
const HOVER_DISMISS_DELAY_MS = 100;
|
||||
|
||||
/**
|
||||
* React view state for the tooltip.
|
||||
*
|
||||
* This is the minimal data needed to render:
|
||||
* - current position / CSS style
|
||||
* - whether the tooltip is visible or pinned
|
||||
* - the React node to show as contents
|
||||
* - the associated uPlot instance (for children)
|
||||
*
|
||||
* All interaction logic lives in the controller; that logic calls
|
||||
* `updateState` to push the latest snapshot into React.
|
||||
*/
|
||||
function createInitialViewState(): TooltipViewState {
|
||||
return {
|
||||
style: { transform: '', pointerEvents: 'none' },
|
||||
isHovering: false,
|
||||
isPinned: false,
|
||||
contents: null,
|
||||
plot: null,
|
||||
dismiss: (): void => {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and wires a ResizeObserver that keeps track of the rendered
|
||||
* tooltip size. This is used by the controller to place the tooltip
|
||||
* on the correct side of the cursor and avoid clipping the viewport.
|
||||
*/
|
||||
function createLayoutObserver(
|
||||
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>,
|
||||
): TooltipLayoutInfo {
|
||||
const layout: TooltipLayoutInfo = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
observer: new ResizeObserver((entries) => {
|
||||
const current = layoutRef.current;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.borderBoxSize?.length) {
|
||||
current.width = entry.borderBoxSize[0].inlineSize;
|
||||
current.height = entry.borderBoxSize[0].blockSize;
|
||||
} else {
|
||||
current.width = entry.contentRect.width;
|
||||
current.height = entry.contentRect.height;
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
return layout;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function TooltipPlugin({
|
||||
config,
|
||||
render,
|
||||
maxWidth = 300,
|
||||
maxHeight = 400,
|
||||
syncMode = DashboardCursorSync.None,
|
||||
syncKey = '_tooltip_sync_global_',
|
||||
isPinningTooltipEnabled = false,
|
||||
}: TooltipPluginProps): JSX.Element | null {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const portalRoot = useRef<HTMLElement>(document.body);
|
||||
const rafId = useRef<number | null>(null);
|
||||
const layoutRef = useRef<TooltipLayoutInfo>();
|
||||
const renderRef = useRef(render);
|
||||
renderRef.current = render;
|
||||
|
||||
// React-managed snapshot of what should be rendered. The controller
|
||||
// owns the interaction state and calls `updateState` when a visible
|
||||
// change should trigger a React re-render.
|
||||
const [viewState, setState] = useState<TooltipViewState>(
|
||||
createInitialViewState,
|
||||
);
|
||||
const { plot, isHovering, isPinned, contents, style } = viewState;
|
||||
|
||||
/**
|
||||
* Merge a partial view update into the current React state.
|
||||
* Style is merged shallowly so callers can update transform /
|
||||
* pointerEvents without having to rebuild the whole object.
|
||||
*/
|
||||
function updateState(updates: Partial<TooltipViewState>): void {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
...updates,
|
||||
style: { ...prev.style, ...updates.style },
|
||||
}));
|
||||
}
|
||||
|
||||
useLayoutEffect((): (() => void) => {
|
||||
layoutRef.current?.observer.disconnect();
|
||||
layoutRef.current = createLayoutObserver(layoutRef);
|
||||
|
||||
// Controller holds the mutable interaction state for this tooltip
|
||||
// instance. It is intentionally *not* React state so uPlot hooks
|
||||
// and DOM listeners can update it freely without triggering a
|
||||
// render on every mouse move.
|
||||
const controller: TooltipControllerState = createInitialControllerState();
|
||||
|
||||
const syncTooltipWithDashboard = syncMode === DashboardCursorSync.Tooltip;
|
||||
|
||||
// Enable uPlot's built-in cursor sync when requested so that
|
||||
// crosshair / tooltip can follow the dashboard-wide cursor.
|
||||
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
|
||||
config.setCursor({
|
||||
sync: { key: syncKey, scales: ['x', null] },
|
||||
});
|
||||
}
|
||||
|
||||
// Dismiss the tooltip when the user clicks / presses a key
|
||||
// outside the tooltip container while it is pinned.
|
||||
const onOutsideInteraction = (event: Event): void => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(INTERACTIVE_CONTAINER)) {
|
||||
dismissTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
// When pinned we want the tooltip to be mouse-interactive
|
||||
// (for copying values etc.), otherwise it should ignore
|
||||
// pointer events so the chart remains fully clickable.
|
||||
function updatePointerEvents(): void {
|
||||
controller.style = {
|
||||
...controller.style,
|
||||
pointerEvents: controller.pinned ? 'all' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
// Lock uPlot's internal cursor when the tooltip is pinned so
|
||||
// subsequent mouse moves do not move the crosshair.
|
||||
function updateCursorLock(): void {
|
||||
if (controller.plot) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - uPlot internal
|
||||
controller.plot.cursor._lock = controller.pinned;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach / detach global listeners when pin state changes so
|
||||
// we can detect when the user interacts outside the tooltip.
|
||||
function toggleOutsideListeners(enable: boolean): void {
|
||||
if (enable) {
|
||||
document.addEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.addEventListener('keydown', onOutsideInteraction, true);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.removeEventListener('keydown', onOutsideInteraction, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Centralised helper that applies all side effects that depend
|
||||
// on whether the tooltip is currently pinned.
|
||||
function applyPinnedSideEffects(): void {
|
||||
updatePointerEvents();
|
||||
updateCursorLock();
|
||||
toggleOutsideListeners(controller.pinned);
|
||||
}
|
||||
|
||||
// Hide the tooltip and reset the uPlot cursor. This is used
|
||||
// both when the user unpins and when interaction ends.
|
||||
function dismissTooltip(): void {
|
||||
const wasPinned = controller.pinned;
|
||||
controller.pinned = false;
|
||||
controller.hoverActive = false;
|
||||
if (controller.plot) {
|
||||
controller.plot.setCursor({ left: -10, top: -10 });
|
||||
}
|
||||
scheduleRender(wasPinned);
|
||||
}
|
||||
|
||||
// Build the React node to be rendered inside the tooltip by
|
||||
// delegating to the caller-provided `render` function.
|
||||
function createTooltipContents(): React.ReactNode {
|
||||
if (!controller.hoverActive || !controller.plot) {
|
||||
return null;
|
||||
}
|
||||
return renderRef.current({
|
||||
uPlotInstance: controller.plot,
|
||||
dataIndexes: controller.seriesIndexes,
|
||||
seriesIndex: controller.focusedSeriesIndex,
|
||||
isPinned: controller.pinned,
|
||||
dismiss: dismissTooltip,
|
||||
viaSync: controller.cursorDrivenBySync,
|
||||
});
|
||||
}
|
||||
|
||||
// Push the latest controller state into React so the tooltip's
|
||||
// DOM representation catches up with the interaction state.
|
||||
function performRender(): void {
|
||||
controller.renderScheduled = false;
|
||||
rafId.current = null;
|
||||
|
||||
if (controller.pendingPinnedUpdate) {
|
||||
applyPinnedSideEffects();
|
||||
controller.pendingPinnedUpdate = false;
|
||||
}
|
||||
|
||||
updateState({
|
||||
style: controller.style,
|
||||
isPinned: controller.pinned,
|
||||
isHovering: controller.hoverActive,
|
||||
contents: createTooltipContents(),
|
||||
dismiss: dismissTooltip,
|
||||
});
|
||||
}
|
||||
|
||||
// Throttle React re-renders:
|
||||
// - use rAF while hovering for smooth updates
|
||||
// - use a small timeout when hiding to avoid flicker when
|
||||
// briefly leaving and re-entering the plot.
|
||||
function scheduleRender(updatePinned = false): void {
|
||||
if (!controller.renderScheduled) {
|
||||
if (!controller.hoverActive) {
|
||||
setTimeout(performRender, HOVER_DISMISS_DELAY_MS);
|
||||
} else {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
rafId.current = requestAnimationFrame(performRender);
|
||||
}
|
||||
controller.renderScheduled = true;
|
||||
}
|
||||
if (updatePinned) {
|
||||
controller.pendingPinnedUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep controller's windowWidth / windowHeight in sync so that
|
||||
// tooltip positioning can respect the current viewport size.
|
||||
const handleWindowResize = (): void => {
|
||||
updateWindowSize(controller);
|
||||
};
|
||||
|
||||
// When the user scrolls, recompute plot visibility and hide
|
||||
// the tooltip if the scroll originated from inside the plot.
|
||||
const handleScroll = (event: Event): void => {
|
||||
updatePlotVisibility(controller);
|
||||
if (controller.hoverActive && isScrollEventInPlot(event, controller)) {
|
||||
dismissTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
// When pinning is enabled, a click on the plot overlay while
|
||||
// hovering converts the transient tooltip into a pinned one.
|
||||
const handleUPlotOverClick = (u: uPlot, event: MouseEvent): void => {
|
||||
if (
|
||||
event.target === u.over &&
|
||||
controller.hoverActive &&
|
||||
!controller.pinned &&
|
||||
controller.focusedSeriesIndex != null
|
||||
) {
|
||||
setTimeout(() => {
|
||||
controller.pinned = true;
|
||||
scheduleRender(true);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
let overClickHandler: ((event: MouseEvent) => void) | null = null;
|
||||
|
||||
// Called once per uPlot instance; used to store the instance
|
||||
// on the controller and optionally attach the pinning handler.
|
||||
const handleInit = (u: uPlot): void => {
|
||||
controller.plot = u;
|
||||
updateState({ plot: u });
|
||||
if (isPinningTooltipEnabled) {
|
||||
overClickHandler = (event: MouseEvent): void =>
|
||||
handleUPlotOverClick(u, event);
|
||||
u.over.addEventListener('click', overClickHandler);
|
||||
}
|
||||
};
|
||||
|
||||
// If the underlying data changes we drop any pinned tooltip,
|
||||
// since the contents may no longer match the new series data.
|
||||
const handleSetData = (): void => {
|
||||
if (controller.pinned) {
|
||||
dismissTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
// Shared context object passed down into all uPlot hook
|
||||
// handlers so they can interact with the controller and
|
||||
// schedule React updates when needed.
|
||||
const ctx: TooltipControllerContext = {
|
||||
controller,
|
||||
layoutRef,
|
||||
containerRef,
|
||||
rafId,
|
||||
updateState,
|
||||
renderRef,
|
||||
syncMode,
|
||||
syncKey,
|
||||
isPinningTooltipEnabled,
|
||||
createTooltipContents,
|
||||
scheduleRender,
|
||||
dismissTooltip,
|
||||
};
|
||||
|
||||
const handleSetSeries = createSetSeriesHandler(ctx, syncTooltipWithDashboard);
|
||||
const handleSetLegend = createSetLegendHandler(ctx, syncTooltipWithDashboard);
|
||||
const handleSetCursor = createSetCursorHandler(ctx);
|
||||
|
||||
handleWindowResize();
|
||||
|
||||
const removeReadyHook = config.addHook('ready', (): void =>
|
||||
updatePlotVisibility(controller),
|
||||
);
|
||||
const removeInitHook = config.addHook('init', handleInit);
|
||||
const removeSetDataHook = config.addHook('setData', handleSetData);
|
||||
const removeSetSeriesHook = config.addHook('setSeries', handleSetSeries);
|
||||
const removeSetLegendHook = config.addHook('setLegend', handleSetLegend);
|
||||
const removeSetCursorHook = config.addHook('setCursor', handleSetCursor);
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
|
||||
return (): void => {
|
||||
layoutRef.current?.observer.disconnect();
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
document.removeEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.removeEventListener('keydown', onOutsideInteraction, true);
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
}
|
||||
removeReadyHook();
|
||||
removeInitHook();
|
||||
removeSetDataHook();
|
||||
removeSetSeriesHook();
|
||||
removeSetLegendHook();
|
||||
removeSetCursorHook();
|
||||
if (controller.plot && overClickHandler) {
|
||||
controller.plot.over.removeEventListener('click', overClickHandler);
|
||||
overClickHandler = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
useLayoutEffect((): void => {
|
||||
if (!plot || !layoutRef.current) {
|
||||
return;
|
||||
}
|
||||
const layout = layoutRef.current;
|
||||
if (containerRef.current) {
|
||||
layout.observer.disconnect();
|
||||
layout.observer.observe(containerRef.current);
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
layout.width = width;
|
||||
layout.height = height;
|
||||
} else {
|
||||
layout.width = 0;
|
||||
layout.height = 0;
|
||||
}
|
||||
}, [isHovering, plot]);
|
||||
|
||||
if (!plot || !isHovering) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cx('tooltip-plugin-container', { pinned: isPinned })}
|
||||
style={{
|
||||
...style,
|
||||
maxWidth: `${maxWidth}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
width: '100%',
|
||||
}}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
ref={containerRef}
|
||||
>
|
||||
{contents}
|
||||
</div>,
|
||||
portalRoot.current,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import type React from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { TooltipRenderArgs } from '../../components/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
TooltipLayoutInfo,
|
||||
TooltipViewState,
|
||||
} from './types';
|
||||
import {
|
||||
buildTransform,
|
||||
calculateTooltipPosition,
|
||||
isPlotInViewport,
|
||||
} from './utils';
|
||||
|
||||
const WINDOW_OFFSET = 16;
|
||||
|
||||
/**
|
||||
* Mutable, non-React state that drives tooltip behaviour:
|
||||
* - whether the tooltip is active / pinned
|
||||
* - where it should be positioned
|
||||
* - which series / data indexes are active
|
||||
*
|
||||
* This state lives outside of React so that uPlot hooks and DOM
|
||||
* event handlers can update it freely without causing re‑renders
|
||||
* on every tiny interaction. React is only updated when a render
|
||||
* is explicitly scheduled from the plugin.
|
||||
*/
|
||||
export interface TooltipControllerState {
|
||||
plot: uPlot | null;
|
||||
hoverActive: boolean;
|
||||
anySeriesActive: boolean;
|
||||
pinned: boolean;
|
||||
style: TooltipViewState['style'];
|
||||
horizontalOffset: number;
|
||||
verticalOffset: number;
|
||||
seriesIndexes: Array<number | null>;
|
||||
focusedSeriesIndex: number | null;
|
||||
cursorDrivenBySync: boolean;
|
||||
plotWithinViewport: boolean;
|
||||
windowWidth: number;
|
||||
windowHeight: number;
|
||||
renderScheduled: boolean;
|
||||
pendingPinnedUpdate: boolean;
|
||||
}
|
||||
|
||||
export function createInitialControllerState(): TooltipControllerState {
|
||||
return {
|
||||
plot: null,
|
||||
hoverActive: false,
|
||||
anySeriesActive: false,
|
||||
pinned: false,
|
||||
style: { transform: '', pointerEvents: 'none' },
|
||||
horizontalOffset: 0,
|
||||
verticalOffset: 0,
|
||||
seriesIndexes: [],
|
||||
focusedSeriesIndex: null,
|
||||
cursorDrivenBySync: false,
|
||||
plotWithinViewport: false,
|
||||
windowWidth: window.innerWidth - WINDOW_OFFSET,
|
||||
windowHeight: window.innerHeight - WINDOW_OFFSET,
|
||||
renderScheduled: false,
|
||||
pendingPinnedUpdate: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep track of the current window size and clear hover state
|
||||
* when the user resizes while hovering (to avoid an orphan tooltip).
|
||||
*/
|
||||
export function updateWindowSize(controller: TooltipControllerState): void {
|
||||
if (controller.hoverActive && !controller.pinned) {
|
||||
controller.hoverActive = false;
|
||||
}
|
||||
controller.windowWidth = window.innerWidth - WINDOW_OFFSET;
|
||||
controller.windowHeight = window.innerHeight - WINDOW_OFFSET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark whether the plot is currently inside the viewport.
|
||||
* This is used to decide if a synced tooltip should be shown at all.
|
||||
*/
|
||||
export function updatePlotVisibility(controller: TooltipControllerState): void {
|
||||
if (!controller.plot) {
|
||||
controller.plotWithinViewport = false;
|
||||
return;
|
||||
}
|
||||
controller.plotWithinViewport = isPlotInViewport(
|
||||
controller.plot.rect,
|
||||
controller.windowWidth,
|
||||
controller.windowHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to detect whether a scroll event actually happened inside
|
||||
* the plot container. Used so we only dismiss the tooltip when the
|
||||
* user scrolls the chart, not the whole page.
|
||||
*/
|
||||
export function isScrollEventInPlot(
|
||||
event: Event,
|
||||
controller: TooltipControllerState,
|
||||
): boolean {
|
||||
return (
|
||||
event.target instanceof Node &&
|
||||
controller.plot !== null &&
|
||||
event.target.contains(controller.plot.root)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to uPlot hook handlers.
|
||||
*
|
||||
* It gives the handlers access to:
|
||||
* - the shared controller state
|
||||
* - layout / container refs
|
||||
* - the React `updateState` function
|
||||
* - render & dismiss helpers from the plugin
|
||||
*/
|
||||
export interface TooltipControllerContext {
|
||||
controller: TooltipControllerState;
|
||||
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
rafId: React.MutableRefObject<number | null>;
|
||||
updateState: (updates: Partial<TooltipViewState>) => void;
|
||||
renderRef: React.MutableRefObject<
|
||||
(args: TooltipRenderArgs) => React.ReactNode
|
||||
>;
|
||||
syncMode: DashboardCursorSync;
|
||||
syncKey: string;
|
||||
isPinningTooltipEnabled: boolean;
|
||||
createTooltipContents: () => React.ReactNode;
|
||||
scheduleRender: (updatePinned?: boolean) => void;
|
||||
dismissTooltip: () => void;
|
||||
}
|
||||
|
||||
export function shouldShowTooltipForSync(
|
||||
controller: TooltipControllerState,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
controller.plotWithinViewport &&
|
||||
controller.anySeriesActive &&
|
||||
syncTooltipWithDashboard
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowTooltipForInteraction(
|
||||
controller: TooltipControllerState,
|
||||
): boolean {
|
||||
return controller.focusedSeriesIndex != null || controller.anySeriesActive;
|
||||
}
|
||||
|
||||
export function updateHoverState(
|
||||
controller: TooltipControllerState,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): void {
|
||||
// When the cursor is driven by dashboard‑level sync, we only show
|
||||
// the tooltip if the plot is in viewport and at least one series
|
||||
// is active. Otherwise we fall back to local interaction logic.
|
||||
controller.hoverActive = controller.cursorDrivenBySync
|
||||
? shouldShowTooltipForSync(controller, syncTooltipWithDashboard)
|
||||
: shouldShowTooltipForInteraction(controller);
|
||||
}
|
||||
|
||||
export function createSetCursorHandler(
|
||||
ctx: TooltipControllerContext,
|
||||
): (u: uPlot) => void {
|
||||
return (u: uPlot): void => {
|
||||
const { controller, layoutRef, containerRef } = ctx;
|
||||
controller.cursorDrivenBySync = u.cursor.event == null;
|
||||
|
||||
if (!controller.hoverActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { left = -10, top = -10 } = u.cursor;
|
||||
if (left < 0 && top < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientX = u.rect.left + left;
|
||||
const clientY = u.rect.top + top;
|
||||
const layout = layoutRef.current;
|
||||
if (!layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width: layoutWidth, height: layoutHeight } = layout;
|
||||
const offsets = calculateTooltipPosition(
|
||||
clientX,
|
||||
clientY,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
controller.horizontalOffset,
|
||||
controller.verticalOffset,
|
||||
controller.windowWidth,
|
||||
controller.windowHeight,
|
||||
);
|
||||
|
||||
controller.horizontalOffset = offsets.horizontalOffset;
|
||||
controller.verticalOffset = offsets.verticalOffset;
|
||||
|
||||
const transform = buildTransform(
|
||||
clientX,
|
||||
clientY,
|
||||
controller.horizontalOffset,
|
||||
controller.verticalOffset,
|
||||
);
|
||||
|
||||
// If the DOM node is mounted we move it directly to avoid
|
||||
// going through React; otherwise we cache the transform in
|
||||
// controller.style and ask the plugin to re‑render.
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.transform = transform;
|
||||
} else {
|
||||
controller.style = { ...controller.style, transform };
|
||||
ctx.scheduleRender();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createSetLegendHandler(
|
||||
ctx: TooltipControllerContext,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): (u: uPlot) => void {
|
||||
return (u: uPlot): void => {
|
||||
const { controller } = ctx;
|
||||
if (!controller.plot?.cursor?.idxs) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.seriesIndexes = controller.plot.cursor.idxs.slice();
|
||||
controller.anySeriesActive = controller.seriesIndexes.some(
|
||||
(v, i) => i > 0 && v != null,
|
||||
);
|
||||
controller.cursorDrivenBySync = u.cursor.event == null;
|
||||
|
||||
// Track transitions into / out of hover so we can avoid
|
||||
// unnecessary renders when nothing visible has changed.
|
||||
const previousHover = controller.hoverActive;
|
||||
updateHoverState(controller, syncTooltipWithDashboard);
|
||||
|
||||
if (controller.hoverActive || controller.hoverActive !== previousHover) {
|
||||
ctx.scheduleRender();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createSetSeriesHandler(
|
||||
ctx: TooltipControllerContext,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): (u: uPlot, seriesIdx: number | null, opts: uPlot.Series) => void {
|
||||
return (u: uPlot, seriesIdx: number | null, opts: uPlot.Series): void => {
|
||||
const { controller } = ctx;
|
||||
if (!('focus' in opts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remember which series is focused so we can drive hover
|
||||
// logic even when the tooltip is being synced externally.
|
||||
controller.focusedSeriesIndex = seriesIdx ?? null;
|
||||
controller.cursorDrivenBySync = u.cursor.event == null;
|
||||
updateHoverState(controller, syncTooltipWithDashboard);
|
||||
ctx.scheduleRender();
|
||||
};
|
||||
}
|
||||
37
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/types.ts
Normal file
37
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { TooltipRenderArgs } from '../../components/types';
|
||||
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
|
||||
export const TOOLTIP_OFFSET = 10;
|
||||
|
||||
export enum DashboardCursorSync {
|
||||
Crosshair,
|
||||
None,
|
||||
Tooltip,
|
||||
}
|
||||
|
||||
export interface TooltipViewState {
|
||||
plot?: uPlot | null;
|
||||
style: Partial<CSSProperties>;
|
||||
isHovering: boolean;
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
contents?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TooltipLayoutInfo {
|
||||
observer: ResizeObserver;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TooltipPluginProps {
|
||||
config: UPlotConfigBuilder;
|
||||
isPinningTooltipEnabled?: boolean;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
render: (args: TooltipRenderArgs) => React.ReactNode;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
106
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/utils.ts
Normal file
106
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/utils.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { TOOLTIP_OFFSET } from './types';
|
||||
|
||||
export function isPlotInViewport(
|
||||
rect: uPlot.BBox,
|
||||
windowWidth: number,
|
||||
windowHeight: number,
|
||||
): boolean {
|
||||
return (
|
||||
rect.top + rect.height <= windowHeight &&
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.left + rect.width <= windowWidth
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateVerticalOffset(
|
||||
currentOffset: number,
|
||||
clientY: number,
|
||||
tooltipHeight: number,
|
||||
windowHeight: number,
|
||||
): number {
|
||||
const height = tooltipHeight + TOOLTIP_OFFSET;
|
||||
|
||||
if (currentOffset !== 0) {
|
||||
if (clientY + height < windowHeight || clientY - height < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (currentOffset !== -height) {
|
||||
return -height;
|
||||
}
|
||||
return currentOffset;
|
||||
}
|
||||
|
||||
if (clientY + height > windowHeight && clientY - height >= 0) {
|
||||
return -height;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calculateHorizontalOffset(
|
||||
currentOffset: number,
|
||||
clientX: number,
|
||||
tooltipWidth: number,
|
||||
windowWidth: number,
|
||||
): number {
|
||||
const width = tooltipWidth + TOOLTIP_OFFSET;
|
||||
|
||||
if (currentOffset !== 0) {
|
||||
if (clientX + width < windowWidth || clientX - width < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (currentOffset !== -width) {
|
||||
return -width;
|
||||
}
|
||||
return currentOffset;
|
||||
}
|
||||
|
||||
if (clientX + width > windowWidth && clientX - width >= 0) {
|
||||
return -width;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calculateTooltipPosition(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
tooltipWidth: number,
|
||||
tooltipHeight: number,
|
||||
currentHorizontalOffset: number,
|
||||
currentVerticalOffset: number,
|
||||
windowWidth: number,
|
||||
windowHeight: number,
|
||||
): { horizontalOffset: number; verticalOffset: number } {
|
||||
return {
|
||||
horizontalOffset: calculateHorizontalOffset(
|
||||
currentHorizontalOffset,
|
||||
clientX,
|
||||
tooltipWidth,
|
||||
windowWidth,
|
||||
),
|
||||
verticalOffset: calculateVerticalOffset(
|
||||
currentVerticalOffset,
|
||||
clientY,
|
||||
tooltipHeight,
|
||||
windowHeight,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTransform(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
hOffset: number,
|
||||
vOffset: number,
|
||||
): string {
|
||||
const translateX =
|
||||
clientX + (hOffset === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
|
||||
const translateY =
|
||||
clientY + (vOffset === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
|
||||
const reflectX = hOffset === 0 ? '' : 'translateX(-100%)';
|
||||
const reflectY = vOffset === 0 ? '' : 'translateY(-100%)';
|
||||
|
||||
return `translateX(${translateX}px) ${reflectX} translateY(${translateY}px) ${reflectY}`;
|
||||
}
|
||||
Reference in New Issue
Block a user