diff --git a/e2e-playwright/panels-suite/timeseries.spec.ts b/e2e-playwright/panels-suite/timeseries.spec.ts new file mode 100644 index 00000000000..fb2124f7dda --- /dev/null +++ b/e2e-playwright/panels-suite/timeseries.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 2431d9ecff5..27322714477 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -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 = {}; 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(type: T, hook: Hooks.Defs[T]) { pluginLog('UPlotConfigBuilder', false, 'addHook', type); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx new file mode 100644 index 00000000000..d9a6e787251 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx @@ -0,0 +1,159 @@ +import uPlot from 'uplot'; + +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; + +import { calculatePanRange, setupXAxisPan } from './XAxisInteractionAreaPlugin'; + +const asUPlot = (partial: Partial) => partial as uPlot; +const asConfigBuilder = (partial: Partial) => 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; +}; + +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; +}; + +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; + let xAxisElement: HTMLElement; + let mockUPlot: ReturnType; + + 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 }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx new file mode 100644 index 00000000000..694f372307d --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx @@ -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; +}; diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts index b9082ae9901..b46ce1d33bb 100644 --- a/packages/grafana-ui/src/index.ts +++ b/packages/grafana-ui/src/index.ts @@ -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'; diff --git a/public/app/core/components/TimeSeries/utils.ts b/public/app/core/components/TimeSeries/utils.ts index 82c18cdd359..f81de163b7f 100644 --- a/public/app/core/components/TimeSeries/utils.ts +++ b/public/app/core/components/TimeSeries/utils.ts @@ -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()]; }, diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index bb0a93310a8..24f6be1672e 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -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 && ( )} + {options.tooltip.mode !== TooltipDisplayMode.None && (