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:
Jesse David Peterson
2025-11-13 06:33:09 -04:00
committed by GitHub
parent 97a6ab7b1c
commit 108693eb31
7 changed files with 466 additions and 1 deletions
@@ -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;
};
+1
View File
@@ -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}