TimeSeries: make cursor hover the nearest non-null/undefined datapoint (#34552)

This commit is contained in:
Leon Sorokin 2021-07-23 16:05:09 -05:00 committed by GitHub
parent 32b74e75a3
commit 4c3e197e26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 87 additions and 28 deletions

View File

@ -69,6 +69,7 @@ Object {
},
],
"cursor": Object {
"dataIdx": [Function],
"drag": Object {
"setScale": false,
},

View File

@ -18,7 +18,9 @@ function applySpanNullsThresholds(frame: DataFrame) {
let spanNulls = field.config.custom?.spanNulls;
if (typeof spanNulls === 'number') {
field.values = new ArrayVector(nullToUndefThreshold(refValues, field.values.toArray(), spanNulls));
if (spanNulls !== -1) {
field.values = new ArrayVector(nullToUndefThreshold(refValues, field.values.toArray(), spanNulls));
}
}
}
}

View File

@ -262,6 +262,58 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
builder.scaleKeys = [xScaleKey, yScaleKey];
// if hovered value is null, how far we may scan left/right to hover nearest non-null
const hoverProximityPx = 15;
let cursor: Partial<uPlot.Cursor> = {
// this scans left and right from cursor position to find nearest data index with value != null
// TODO: do we want to only scan past undefined values, but halt at explicit null values?
dataIdx: (self, seriesIdx, hoveredIdx, cursorXVal) => {
let seriesData = self.data[seriesIdx];
if (seriesData[hoveredIdx] == null) {
let nonNullLft = hoveredIdx,
nonNullRgt = hoveredIdx,
i;
i = hoveredIdx;
while (nonNullLft === hoveredIdx && i-- > 0) {
if (seriesData[i] != null) {
nonNullLft = i;
}
}
i = hoveredIdx;
while (nonNullRgt === hoveredIdx && i++ < seriesData.length) {
if (seriesData[i] != null) {
nonNullRgt = i;
}
}
let xVals = self.data[0];
let curPos = self.valToPos(cursorXVal, 'x');
let rgtPos = self.valToPos(xVals[nonNullRgt], 'x');
let lftPos = self.valToPos(xVals[nonNullLft], 'x');
let lftDelta = curPos - lftPos;
let rgtDelta = rgtPos - curPos;
if (lftDelta <= rgtDelta) {
if (lftDelta <= hoverProximityPx) {
hoveredIdx = nonNullLft;
}
} else {
if (rgtDelta <= hoverProximityPx) {
hoveredIdx = nonNullRgt;
}
}
}
return hoveredIdx;
},
};
if (sync !== DashboardCursorSync.Off) {
const payload: DataHoverPayload = {
point: {
@ -271,34 +323,34 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
data: frame,
};
const hoverEvent = new DataHoverEvent(payload);
builder.setSync();
builder.setCursor({
sync: {
key: '__global_',
filters: {
pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
payload.columnIndex = dataIdx;
if (x < 0 && y < 0) {
payload.point[xScaleUnit] = null;
payload.point[yScaleKey] = null;
eventBus.publish(new DataHoverClearEvent(payload));
} else {
// convert the points
payload.point[xScaleUnit] = src.posToVal(x, xScaleKey);
payload.point[yScaleKey] = src.posToVal(y, yScaleKey);
eventBus.publish(hoverEvent);
hoverEvent.payload.down = undefined;
}
return true;
},
cursor.sync = {
key: '__global_',
filters: {
pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
payload.columnIndex = dataIdx;
if (x < 0 && y < 0) {
payload.point[xScaleUnit] = null;
payload.point[yScaleKey] = null;
eventBus.publish(new DataHoverClearEvent(payload));
} else {
// convert the points
payload.point[xScaleUnit] = src.posToVal(x, xScaleKey);
payload.point[yScaleKey] = src.posToVal(y, yScaleKey);
eventBus.publish(hoverEvent);
hoverEvent.payload.down = undefined;
}
return true;
},
// ??? setSeries: syncMode === DashboardCursorSync.Tooltip,
scales: builder.scaleKeys,
match: [() => true, () => true],
},
});
// ??? setSeries: syncMode === DashboardCursorSync.Tooltip,
scales: builder.scaleKeys,
match: [() => true, () => true],
};
}
builder.setSync();
builder.setCursor(cursor);
return builder;
};

View File

@ -43,6 +43,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
const plotCtx = usePlotContext();
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
const [focusedPointIdxs, setFocusedPointIdxs] = useState<Array<number | null>>([]);
const [coords, setCoords] = useState<CartesianCoords2D | null>(null);
const plotInstance = plotCtx.plot;
@ -93,10 +94,13 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
})(u);
});
} else {
config.addHook('setLegend', (u) => {
setFocusedPointIdx(u.cursor.idx!);
setFocusedPointIdxs(u.cursor.idxs!.slice());
});
// default series/datapoint idx retireval
config.addHook('setCursor', (u) => {
setFocusedPointIdx(u.cursor.idx === undefined ? u.posToIdx(u.cursor.left || 0) : u.cursor.idx);
const bbox = plotCtx.getCanvasBoundingBox();
if (!bbox) {
return;
@ -174,7 +178,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
continue;
}
const display = field.display!(otherProps.data.fields[i].values.get(focusedPointIdx));
const display = field.display!(otherProps.data.fields[i].values.get(focusedPointIdxs[i]!));
series.push({
color: display.color || FALLBACK_COLOR,