TimeRange: Avoid x-axis pan jump caused by data loading latency (#114496)

* fix(time-range): avoid x-axis pan jump caused by data loading latency

* refactor(time-range): use a more semantically meaningful names
This commit is contained in:
Jesse David Peterson
2025-11-26 20:41:02 +00:00
committed by GitHub
parent b08e7bc373
commit 399370bc2c
5 changed files with 57 additions and 6 deletions
@@ -29,7 +29,7 @@ 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 };
type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number; isTimeRangePending?: boolean };
export class UPlotConfigBuilder {
readonly uid = Math.random().toString(36).slice(2);
@@ -137,7 +137,7 @@ describe('XAxisInteractionAreaPlugin', () => {
expect(mockQueryZoom).not.toHaveBeenCalled();
});
it('should set isPanning state during drag and clear on mouseup', () => {
it('should set isPanning state during drag and mark isTimeRangePending on mouseup', () => {
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
@@ -153,6 +153,20 @@ describe('XAxisInteractionAreaPlugin', () => {
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true }));
expect(mockConfigBuilder.setState).toHaveBeenCalledWith({
isPanning: true,
min: expectedRange.from,
max: expectedRange.to,
isTimeRangePending: true,
});
});
it('should clear isPanning state immediately for small drags 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(mockConfigBuilder.setState).toHaveBeenCalledWith({ isPanning: false });
});
});
@@ -96,11 +96,14 @@ export const setupXAxisPan = (
xAxisEl.style.cursor = 'grab';
config.setState({ isPanning: false });
const isSignificantDrag = Math.abs(dragPixels) >= MIN_PAN_DIST;
if (Math.abs(dragPixels) >= MIN_PAN_DIST) {
if (isSignificantDrag) {
const newRange = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width);
config.setState({ isPanning: true, min: newRange.from, max: newRange.to, isTimeRangePending: true });
queryZoom(newRange);
} else {
config.setState({ isPanning: false });
}
document.removeEventListener('mousemove', onMove);
+18 -2
View File
@@ -138,10 +138,26 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
range: () => {
const state = builder.getState();
if (state.isPanning) {
if (state.isTimeRangePending) {
const timeRange = getTimeRange();
const propsFrom = timeRange.from.valueOf();
const propsTo = timeRange.to.valueOf();
const MIN_TIMESPAN_MS = 1;
const fromMatches = Math.abs(propsFrom - state.min) <= MIN_TIMESPAN_MS;
const toMatches = Math.abs(propsTo - state.max) <= MIN_TIMESPAN_MS;
const timeRangeHasUpdated = fromMatches && toMatches;
if (timeRangeHasUpdated) {
builder.setState({ isPanning: false });
return [propsFrom, propsTo];
}
}
return [state.min, state.max];
}
const r = getTimeRange();
return [r.from.valueOf(), r.to.valueOf()];
const timeRange = getTimeRange();
return [timeRange.from.valueOf(), timeRange.to.valueOf()];
},
});
@@ -159,6 +159,24 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
range: (u) => {
const state = builder.getState();
if (state.isPanning) {
if (state.isTimeRangePending) {
const propsRange = coreConfig.xRange(u);
const propsFrom = propsRange[0];
const propsTo = propsRange[1];
if (propsFrom != null && propsTo != null) {
const MIN_TIMESPAN_MS = 1;
const fromMatches = Math.abs(propsFrom - state.min) <= MIN_TIMESPAN_MS;
const toMatches = Math.abs(propsTo - state.max) <= MIN_TIMESPAN_MS;
const timeRangeHasUpdated = fromMatches && toMatches;
if (timeRangeHasUpdated) {
builder.setState({ isPanning: false });
return propsRange;
}
}
}
return [state.min, state.max];
}
return coreConfig.xRange(u);