diff --git a/__tests__/integration/snapshots/tooltip/mock-tooltip-closest/step0.html b/__tests__/integration/snapshots/tooltip/mock-tooltip-closest/step0.html
new file mode 100644
index 0000000000..9e692e0086
--- /dev/null
+++ b/__tests__/integration/snapshots/tooltip/mock-tooltip-closest/step0.html
@@ -0,0 +1,75 @@
+
\ No newline at end of file
diff --git a/__tests__/integration/snapshots/tooltip/mock-tooltip-closest/step1.html b/__tests__/integration/snapshots/tooltip/mock-tooltip-closest/step1.html
new file mode 100644
index 0000000000..41a6f6e7c9
--- /dev/null
+++ b/__tests__/integration/snapshots/tooltip/mock-tooltip-closest/step1.html
@@ -0,0 +1,75 @@
+
\ No newline at end of file
diff --git a/__tests__/plots/tooltip/index.ts b/__tests__/plots/tooltip/index.ts
index 025b0085f2..3640ab993f 100644
--- a/__tests__/plots/tooltip/index.ts
+++ b/__tests__/plots/tooltip/index.ts
@@ -65,3 +65,4 @@ export { pointsPointRegressionQuad } from './points-point-regression-quad';
export { alphabetIntervalTooltipRenderUpdate } from './alphabet-interval-tooltip-render-update';
export { mockIntervalShared } from './mock-interval-shared';
export { stateAgesIntervalCustomStyle } from './stateages-interval-custom-style';
+export { mockTooltipClosest } from './mock-tooltip-closest';
diff --git a/__tests__/plots/tooltip/mock-tooltip-closest.ts b/__tests__/plots/tooltip/mock-tooltip-closest.ts
new file mode 100644
index 0000000000..c12e9b1a0a
--- /dev/null
+++ b/__tests__/plots/tooltip/mock-tooltip-closest.ts
@@ -0,0 +1,39 @@
+import { seriesTooltipSteps } from './utils';
+
+export function mockTooltipClosest() {
+ return {
+ type: 'view',
+ data: [
+ { time: '10:10', call: 4, waiting: 2, people: 2 },
+ { time: '10:15', call: 2, waiting: 6, people: 3 },
+ { time: '10:20', call: 13, waiting: 2, people: 5 },
+ { time: '10:25', call: 9, waiting: 9, people: 1 },
+ { time: '10:30', call: 5, waiting: 2, people: 3 },
+ { time: '10:35', call: 8, waiting: 2, people: 1 },
+ { time: '10:40', call: 13, waiting: 1, people: 2 },
+ ],
+ children: [
+ {
+ type: 'interval',
+ encode: { x: 'time', y: 'waiting' },
+ axis: { y: { title: 'Waiting', titleFill: '#5B8FF9' } },
+ },
+ {
+ type: 'line',
+ encode: { x: 'time', y: 'people', shape: 'smooth' },
+ scale: { y: { independent: true } },
+ style: { stroke: '#fdae6b', lineWidth: 2 },
+ axis: {
+ y: {
+ position: 'right',
+ grid: null,
+ title: 'People',
+ titleFill: '#fdae6b',
+ },
+ },
+ },
+ ],
+ };
+}
+
+mockTooltipClosest.steps = seriesTooltipSteps([570, 300], [145, 300]);
diff --git a/src/interaction/tooltip.ts b/src/interaction/tooltip.ts
index 6af3434594..0bfbfda978 100644
--- a/src/interaction/tooltip.ts
+++ b/src/interaction/tooltip.ts
@@ -457,11 +457,14 @@ export function seriesTooltip(
const itemElements = [];
for (const element of elements) {
const { __data__: data } = element;
- const { seriesX } = data;
+ const { seriesX, title, items } = data;
if (seriesX) seriesElements.push(element);
- else itemElements.push(element);
+ else if (title || items) itemElements.push(element);
}
+ const isBandScale = !!(transposed ? scale.y : scale.x).getBandWidth;
+ const closest = isBandScale && itemElements.length > 0;
+
// Sorted elements from top to bottom visually,
// or from right to left in transpose coordinate.
seriesElements.sort((a, b) => {
@@ -470,6 +473,20 @@ export function seriesTooltip(
return transposed ? minY(b) - minY(a) : minY(a) - minY(b);
});
+ const extent = (d) => {
+ const index = transposed ? 1 : 0;
+ const { min, max } = d.getLocalBounds();
+ return sort([min[index], max[index]]);
+ };
+ // Sort itemElements by x or y.
+ itemElements.sort((a, b) => {
+ const [minA, maxA] = extent(a);
+ const [minB, maxB] = extent(b);
+ const midA = (minA + maxA) / 2;
+ const midB = (minB + maxB) / 2;
+ return transposed ? midB - midA : midA - midB;
+ });
+
// Get sortedIndex and X for each series elements
const elementSortedX = new Map(
seriesElements.map((element) => {
@@ -494,8 +511,9 @@ export function seriesTooltip(
const indexByFocus = (focus, I, X) => {
const finalX = abstractX(focus);
const [minX, maxX] = sort([X[0], X[X.length - 1]]);
- // Skip x out of range.
- if (finalX < minX || finalX > maxX) return null;
+ // If closest is true, always find at least one element.
+ // Otherwise, skip element out of plot area.
+ if (!closest && (finalX < minX || finalX > maxX)) return null;
const search = bisector((i) => X[+i]).center;
const i = search(I, finalX);
return I[i];
@@ -504,14 +522,20 @@ export function seriesTooltip(
const elementsByFocus = (focus, elements) => {
const index = transposed ? 1 : 0;
const x = focus[index];
- const extent = (d) => {
- const { min, max } = d.getLocalBounds();
- return sort([min[index], max[index]]);
- };
- return elements.filter((element) => {
+ const filtered = elements.filter((element) => {
const [min, max] = extent(element);
return x >= min && x <= max;
});
+ // If closet is true, always find at least one element.
+ if (!closest || filtered.length > 0) return filtered;
+
+ // Search the closet element to the focus.
+ const search = bisector((element) => {
+ const [min, max] = extent(element);
+ return (min + max) / 2;
+ }).center;
+ const i = search(elements, x);
+ return [elements[i]].filter(defined);
};
const seriesData = (element, index) => {