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 @@ +
+
+ 10:40 +
+ +
\ 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 @@ +
+
+ 10:15 +
+ +
\ 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) => {