TimeSeries: X-axis (time range) click-and-drag panning in panel (#112982)
* feat(time-range): click and drag interaction to pan x-axis over time * feat(timeseries): add x-axis interaction area uPlot plugin * test(timeseries): validate x-axis pan with Playwright browser test * refactor(uplot-config): simplify state management for x-axis panning * refactor(uplot-config): plot state union type * fix(time-range-pan): simplify calcs, mouse handler cleanup function
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
const DASHBOARD_UID = '1KxMUdE7k';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
timeRangePan: true,
|
||||
},
|
||||
});
|
||||
|
||||
test.describe('Panels test: TimeSeries X-axis panning', { tag: ['@panels', '@timeseries'] }, () => {
|
||||
test('cursor changes to grab hand over x-axis', async ({ gotoDashboardPage, page }) => {
|
||||
await test.step('Load dashboard and verify cursor changes to grab', async () => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID });
|
||||
|
||||
const timeseriesPanel = page.locator('.uplot').first();
|
||||
await expect(timeseriesPanel, 'panel rendered').toBeVisible();
|
||||
|
||||
const xAxis = timeseriesPanel.locator('.u-axis').first();
|
||||
await expect(xAxis, 'x-axis rendered').toBeVisible();
|
||||
|
||||
await xAxis.hover();
|
||||
|
||||
const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor);
|
||||
expect(cursorStyle, 'cursor is grab').toBe('grab');
|
||||
});
|
||||
});
|
||||
|
||||
test('drag right pans backward in time, drag left pans forward', async ({ gotoDashboardPage, page, selectors }) => {
|
||||
let centerX: number;
|
||||
let centerY: number;
|
||||
let initialFromTime: number;
|
||||
let initialToTime: number;
|
||||
|
||||
const dashboardPage = await test.step('Load dashboard and capture initial time range', async () => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID });
|
||||
|
||||
const timeseriesPanel = page.locator('.uplot').first();
|
||||
await expect(timeseriesPanel, 'panel rendered').toBeVisible();
|
||||
|
||||
const xAxis = timeseriesPanel.locator('.u-axis').first();
|
||||
await expect(xAxis, 'x-axis rendered').toBeVisible();
|
||||
|
||||
const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton);
|
||||
await timePickerButton.click();
|
||||
|
||||
const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField);
|
||||
const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField);
|
||||
|
||||
const initialFrom = await fromField.inputValue();
|
||||
const initialTo = await toField.inputValue();
|
||||
initialFromTime = new Date(initialFrom).getTime();
|
||||
initialToTime = new Date(initialTo).getTime();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const axisBox = await xAxis.boundingBox();
|
||||
if (!axisBox) {
|
||||
throw new Error('X-axis bounding box not found');
|
||||
}
|
||||
|
||||
centerX = axisBox.x + axisBox.width / 2;
|
||||
centerY = axisBox.y + axisBox.height / 2;
|
||||
|
||||
return dashboardPage;
|
||||
});
|
||||
|
||||
await test.step('Drag right pans backward in time', async () => {
|
||||
await page.mouse.move(centerX, centerY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX + 100, centerY);
|
||||
await page.mouse.up();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton);
|
||||
await timePickerButton.click();
|
||||
|
||||
const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField);
|
||||
const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField);
|
||||
|
||||
const afterRightFrom = await fromField.inputValue();
|
||||
const afterRightTo = await toField.inputValue();
|
||||
const afterRightFromTime = new Date(afterRightFrom).getTime();
|
||||
const afterRightToTime = new Date(afterRightTo).getTime();
|
||||
|
||||
expect(afterRightFromTime, 'panned backward').toBeLessThan(initialFromTime);
|
||||
expect(afterRightToTime, 'panned backward').toBeLessThan(initialToTime);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
initialFromTime = afterRightFromTime;
|
||||
initialToTime = afterRightToTime;
|
||||
});
|
||||
|
||||
await test.step('Drag left pans forward in time', async () => {
|
||||
await page.mouse.move(centerX, centerY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX - 100, centerY);
|
||||
await page.mouse.up();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton);
|
||||
await timePickerButton.click();
|
||||
|
||||
const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField);
|
||||
const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField);
|
||||
|
||||
const afterLeftFrom = await fromField.inputValue();
|
||||
const afterLeftTo = await toField.inputValue();
|
||||
const afterLeftFromTime = new Date(afterLeftFrom).getTime();
|
||||
const afterLeftToTime = new Date(afterLeftTo).getTime();
|
||||
|
||||
expect(afterLeftFromTime, 'panned forward').toBeGreaterThan(initialFromTime);
|
||||
expect(afterLeftToTime, 'panned forward').toBeGreaterThan(initialToTime);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,8 @@ const cursorDefaults: Cursor = {
|
||||
type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData;
|
||||
type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData;
|
||||
|
||||
type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number };
|
||||
|
||||
export class UPlotConfigBuilder {
|
||||
readonly uid = Math.random().toString(36).slice(2);
|
||||
|
||||
@@ -47,6 +49,7 @@ export class UPlotConfigBuilder {
|
||||
// to prevent more than one threshold per scale
|
||||
private thresholds: Record<string, UPlotThresholdOptions> = {};
|
||||
private padding?: Padding = undefined;
|
||||
private state: PlotState = { isPanning: false };
|
||||
|
||||
private cachedConfig?: PlotConfig;
|
||||
|
||||
@@ -59,6 +62,14 @@ export class UPlotConfigBuilder {
|
||||
// Exposed to let the container know the primary scale keys
|
||||
scaleKeys: [string, string] = ['', ''];
|
||||
|
||||
setState(state: PlotState) {
|
||||
this.state = merge({}, this.state, state);
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
addHook<T extends keyof Hooks.Defs>(type: T, hook: Hooks.Defs[T]) {
|
||||
pluginLog('UPlotConfigBuilder', false, 'addHook', type);
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
|
||||
import { calculatePanRange, setupXAxisPan } from './XAxisInteractionAreaPlugin';
|
||||
|
||||
const asUPlot = (partial: Partial<uPlot>) => partial as uPlot;
|
||||
const asConfigBuilder = (partial: Partial<UPlotConfigBuilder>) => partial as UPlotConfigBuilder;
|
||||
|
||||
const createMockXAxis = () => {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('u-axis');
|
||||
return element;
|
||||
};
|
||||
|
||||
const createMockConfigBuilder = () => {
|
||||
return {
|
||||
setState: jest.fn(),
|
||||
getState: jest.fn(() => ({ isPanning: false })),
|
||||
} satisfies Partial<UPlotConfigBuilder>;
|
||||
};
|
||||
|
||||
const createMockUPlot = (xAxisElement: HTMLElement) => {
|
||||
const root = document.createElement('div');
|
||||
root.appendChild(xAxisElement);
|
||||
|
||||
const over = document.createElement('div');
|
||||
Object.defineProperty(over, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: 800, height: 400 }),
|
||||
});
|
||||
|
||||
return {
|
||||
root,
|
||||
over,
|
||||
bbox: { width: 800, height: 400, left: 0, top: 0 },
|
||||
scales: {
|
||||
x: {
|
||||
min: 1000,
|
||||
max: 2000,
|
||||
range: () => [1000, 2000],
|
||||
},
|
||||
},
|
||||
setScale: jest.fn(),
|
||||
} satisfies Partial<uPlot>;
|
||||
};
|
||||
|
||||
describe('XAxisInteractionAreaPlugin', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('calculatePanRange', () => {
|
||||
it('should calculate pan range correctly for positive and negative drag', () => {
|
||||
const timeFrom = 1000;
|
||||
const timeTo = 2000;
|
||||
const plotWidth = 800;
|
||||
|
||||
const dragRight100px = calculatePanRange(timeFrom, timeTo, 100, plotWidth);
|
||||
expect(dragRight100px.from).toBeCloseTo(875, 1);
|
||||
expect(dragRight100px.to).toBeCloseTo(1875, 1);
|
||||
|
||||
const dragLeft100px = calculatePanRange(timeFrom, timeTo, -100, plotWidth);
|
||||
expect(dragLeft100px.from).toBeCloseTo(1125, 1);
|
||||
expect(dragLeft100px.to).toBeCloseTo(2125, 1);
|
||||
});
|
||||
|
||||
it('should return original range when not dragged', () => {
|
||||
const noDrag = calculatePanRange(1000, 2000, 0, 800);
|
||||
expect(noDrag.from).toBe(1000);
|
||||
expect(noDrag.to).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupXAxisPan', () => {
|
||||
let mockQueryZoom: jest.Mock;
|
||||
let mockConfigBuilder: ReturnType<typeof createMockConfigBuilder>;
|
||||
let xAxisElement: HTMLElement;
|
||||
let mockUPlot: ReturnType<typeof createMockUPlot>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockQueryZoom = jest.fn();
|
||||
mockConfigBuilder = createMockConfigBuilder();
|
||||
xAxisElement = createMockXAxis();
|
||||
mockUPlot = createMockUPlot(xAxisElement);
|
||||
document.body.appendChild(mockUPlot.root!);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle missing x-axis element gracefully', () => {
|
||||
const emptyRoot = document.createElement('div');
|
||||
const emptyUPlot = { ...mockUPlot, root: emptyRoot };
|
||||
|
||||
expect(() => setupXAxisPan(asUPlot(emptyUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should show grab cursor on hover and grabbing during drag', () => {
|
||||
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
|
||||
|
||||
xAxisElement.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
expect(xAxisElement).toHaveStyle({ cursor: 'grab' });
|
||||
|
||||
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
|
||||
expect(xAxisElement).toHaveStyle({ cursor: 'grabbing' });
|
||||
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true }));
|
||||
expect(xAxisElement).toHaveStyle({ cursor: 'grab' });
|
||||
});
|
||||
|
||||
it('should update scale during drag and call queryZoom on completion', () => {
|
||||
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
|
||||
|
||||
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
|
||||
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 350, bubbles: true }));
|
||||
|
||||
const expectedRange = calculatePanRange(1000, 2000, -50, 800);
|
||||
|
||||
expect(mockUPlot.setScale).toHaveBeenCalledWith('x', {
|
||||
min: expectedRange.from,
|
||||
max: expectedRange.to,
|
||||
});
|
||||
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true }));
|
||||
|
||||
expect(mockQueryZoom).toHaveBeenCalledWith(expectedRange);
|
||||
});
|
||||
|
||||
it('should not call queryZoom when drag distance is below threshold', () => {
|
||||
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
|
||||
|
||||
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 402, bubbles: true }));
|
||||
|
||||
expect(mockQueryZoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set isPanning state during drag and clear on mouseup', () => {
|
||||
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
|
||||
|
||||
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
|
||||
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 350, bubbles: true }));
|
||||
|
||||
const expectedRange = calculatePanRange(1000, 2000, -50, 800);
|
||||
|
||||
expect(mockConfigBuilder.setState).toHaveBeenCalledWith({
|
||||
isPanning: true,
|
||||
min: expectedRange.from,
|
||||
max: expectedRange.to,
|
||||
});
|
||||
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true }));
|
||||
|
||||
expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ isPanning: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { getFeatureToggle } from '../../../utils/featureToggle';
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
|
||||
const MIN_PAN_DIST = 5;
|
||||
|
||||
/**
|
||||
* Calculates the new time range after a pan operation.
|
||||
*
|
||||
* @returns Object containing the new from and to time values
|
||||
* @internal - exported for testing only
|
||||
*/
|
||||
export const calculatePanRange = (
|
||||
timeFrom: number,
|
||||
timeTo: number,
|
||||
dragPixels: number,
|
||||
plotWidth: number
|
||||
): { from: number; to: number } => {
|
||||
const unitsPerPx = (timeTo - timeFrom) / (plotWidth / uPlot.pxRatio);
|
||||
const timeShift = dragPixels * unitsPerPx;
|
||||
|
||||
return {
|
||||
from: timeFrom - timeShift,
|
||||
to: timeTo - timeShift,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Enables panning the time range by click and dragging x-axis labels with the mouse.
|
||||
* Provides visual feedback (grab/grabbing cursor) and real-time grid updates during drag.
|
||||
*
|
||||
* @returns Cleanup function to remove event listeners
|
||||
* @internal - exported for testing only
|
||||
*/
|
||||
export const setupXAxisPan = (
|
||||
u: uPlot,
|
||||
config: UPlotConfigBuilder,
|
||||
queryZoom: (range: { from: number; to: number }) => void
|
||||
): (() => void) => {
|
||||
let xAxes = u.root.querySelectorAll('.u-axis');
|
||||
let xAxis = xAxes[0];
|
||||
|
||||
if (!xAxis || !(xAxis instanceof HTMLElement)) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const xAxisEl = xAxis;
|
||||
|
||||
let activeMoveListener: ((e: MouseEvent) => void) | null = null;
|
||||
let activeUpListener: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
xAxisEl.style.cursor = 'grab';
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
xAxisEl.style.cursor = '';
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: Event) => {
|
||||
if (!(e instanceof MouseEvent)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
xAxisEl.style.cursor = 'grabbing';
|
||||
|
||||
let xScale = u.scales.x;
|
||||
|
||||
let rect = u.over.getBoundingClientRect();
|
||||
let startX = e.clientX - rect.left;
|
||||
let startMin = xScale.min!;
|
||||
let startMax = xScale.max!;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
let currentX = e.clientX - rect.left;
|
||||
let dragPixels = currentX - startX;
|
||||
|
||||
const { from, to } = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width);
|
||||
|
||||
config.setState({ isPanning: true, min: from, max: to });
|
||||
|
||||
u.setScale('x', {
|
||||
min: from,
|
||||
max: to,
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = (e: MouseEvent) => {
|
||||
let endX = e.clientX - rect.left;
|
||||
let dragPixels = endX - startX;
|
||||
|
||||
xAxisEl.style.cursor = 'grab';
|
||||
|
||||
config.setState({ isPanning: false });
|
||||
|
||||
if (Math.abs(dragPixels) >= MIN_PAN_DIST) {
|
||||
const newRange = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width);
|
||||
queryZoom(newRange);
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
activeMoveListener = null;
|
||||
activeUpListener = null;
|
||||
};
|
||||
|
||||
activeMoveListener = onMove;
|
||||
activeUpListener = onUp;
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
xAxisEl.addEventListener('mouseenter', handleMouseEnter);
|
||||
xAxisEl.addEventListener('mouseleave', handleMouseLeave);
|
||||
xAxisEl.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
return () => {
|
||||
xAxisEl.removeEventListener('mouseenter', handleMouseEnter);
|
||||
xAxisEl.removeEventListener('mouseleave', handleMouseLeave);
|
||||
xAxisEl.removeEventListener('mousedown', handleMouseDown);
|
||||
|
||||
if (activeMoveListener) {
|
||||
document.removeEventListener('mousemove', activeMoveListener);
|
||||
}
|
||||
if (activeUpListener) {
|
||||
document.removeEventListener('mouseup', activeUpListener);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export interface XAxisInteractionAreaPluginProps {
|
||||
config: UPlotConfigBuilder;
|
||||
queryZoom?: (range: { from: number; to: number }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin for handling x-axis area interactions, such as time range panning.
|
||||
* Properly manages event listener lifecycle to prevent memory leaks.
|
||||
*/
|
||||
export const XAxisInteractionAreaPlugin = ({ config, queryZoom }: XAxisInteractionAreaPluginProps) => {
|
||||
useLayoutEffect(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
config.addHook('init', (u) => {
|
||||
if (queryZoom != null && getFeatureToggle('timeRangePan')) {
|
||||
cleanup = setupXAxisPan(u, config, queryZoom);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [config, queryZoom]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -353,6 +353,7 @@ export { EventsCanvas } from './components/uPlot/geometries/EventsCanvas';
|
||||
export { TooltipPlugin2 } from './components/uPlot/plugins/TooltipPlugin2';
|
||||
export { EventBusPlugin } from './components/uPlot/plugins/EventBusPlugin';
|
||||
export { KeyboardPlugin } from './components/uPlot/plugins/KeyboardPlugin';
|
||||
export { XAxisInteractionAreaPlugin } from './components/uPlot/plugins/XAxisInteractionAreaPlugin';
|
||||
|
||||
export { type PlotTooltipInterpolator, type PlotSelection, FIXED_UNIT } from './components/uPlot/types';
|
||||
export { type UPlotConfigPrepFn } from './components/uPlot/config/UPlotConfigBuilder';
|
||||
|
||||
@@ -136,6 +136,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
|
||||
direction: isHorizontal ? ScaleDirection.Right : ScaleDirection.Up,
|
||||
isTime: true,
|
||||
range: () => {
|
||||
const state = builder.getState();
|
||||
if (state.isPanning) {
|
||||
return [state.min, state.max];
|
||||
}
|
||||
const r = getTimeRange();
|
||||
return [r.from.valueOf(), r.to.valueOf()];
|
||||
},
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { PanelDataErrorView } from '@grafana/runtime';
|
||||
import { TooltipDisplayMode, VizOrientation } from '@grafana/schema';
|
||||
import { EventBusPlugin, KeyboardPlugin, TooltipPlugin2, usePanelContext } from '@grafana/ui';
|
||||
import {
|
||||
EventBusPlugin,
|
||||
KeyboardPlugin,
|
||||
TooltipPlugin2,
|
||||
XAxisInteractionAreaPlugin,
|
||||
usePanelContext,
|
||||
} from '@grafana/ui';
|
||||
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
|
||||
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
||||
import { config } from 'app/core/config';
|
||||
@@ -141,6 +147,7 @@ export const TimeSeriesPanel = ({
|
||||
{cursorSync !== DashboardCursorSync.Off && (
|
||||
<EventBusPlugin config={uplotConfig} eventBus={eventBus} frame={alignedFrame} />
|
||||
)}
|
||||
<XAxisInteractionAreaPlugin config={uplotConfig} queryZoom={onChangeTimeRange} />
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={uplotConfig}
|
||||
|
||||
Reference in New Issue
Block a user