feat: added a new tooltip plugin

This commit is contained in:
Abhi Kumar
2026-02-03 12:57:51 +05:30
parent c79373314a
commit 8d4c4ec43a
5 changed files with 836 additions and 0 deletions

View File

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

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

View File

@@ -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 rerenders
* 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 dashboardlevel 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 rerender.
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();
};
}

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

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