From d879968f67dd734ce9d7e6a6fe7bc8a94102ea1e Mon Sep 17 00:00:00 2001 From: MiniPear Date: Tue, 19 Sep 2023 15:09:51 +0800 Subject: [PATCH] feat(tooltip): support closest --- .../tooltip/mock-tooltip-closest/step0.html | 75 +++++++++++++++++++ .../tooltip/mock-tooltip-closest/step1.html | 75 +++++++++++++++++++ __tests__/plots/tooltip/index.ts | 1 + .../plots/tooltip/mock-tooltip-closest.ts | 46 ++++++++++++ site/docs/spec/interaction/tooltip.zh.md | 43 +++++------ site/examples/general/dual/demo/line-bar.ts | 2 + site/examples/general/dual/demo/multi-line.ts | 2 + site/examples/general/dual/demo/pareto.ts | 2 + src/interaction/tooltip.ts | 36 +++++++-- 9 files changed, 254 insertions(+), 28 deletions(-) create mode 100644 __tests__/integration/snapshots/tooltip/mock-tooltip-closest/step0.html create mode 100644 __tests__/integration/snapshots/tooltip/mock-tooltip-closest/step1.html create mode 100644 __tests__/plots/tooltip/mock-tooltip-closest.ts 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..13fad8a1ef --- /dev/null +++ b/__tests__/plots/tooltip/mock-tooltip-closest.ts @@ -0,0 +1,46 @@ +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', + }, + }, + }, + ], + interaction: { + tooltip: { + closest: true, + }, + }, + }; +} + +mockTooltipClosest.steps = seriesTooltipSteps([570, 300], [145, 300]); + +mockTooltipClosest.only = true; diff --git a/site/docs/spec/interaction/tooltip.zh.md b/site/docs/spec/interaction/tooltip.zh.md index d29615c0b4..1f49fd1c9f 100644 --- a/site/docs/spec/interaction/tooltip.zh.md +++ b/site/docs/spec/interaction/tooltip.zh.md @@ -33,27 +33,28 @@ chart.render(); ## 选项 -| 属性 | 描述 | 类型 | 默认值 | -| ------------------------- | ----------------------------------------------------------------- | ------------------------------------------- | --------------------- | -| wait | 提示信息更新的时间间隔,单位为毫秒 | `number` | 50 | -| leading | 是否在时间间隔开始的时候更新提示信息 | `boolean` | true | -| trailing | 是否在时间间隔结束的时候更新提示信息 | `boolean` | false | -| shared | 相同 x 的元素是否共享 tooltip | `boolean` | false | -| series | 是否是系列元素的 tooltip | `boolean` | - | -| body | 是否展示 tooltip | `boolean` | true | -| marker | 是否展示 marker | `boolean` | true | -| groupName | 是否使用 groupName | `boolean` | true | -| position | tooltip 位置 | `TooltipPosition` | - | -| mount | tooltip 渲染的 dom 节点 | `string` \| `HTMLElement` | 图表容器 | -| bounding | tooltip 渲染的限制区域,超出会自动调整位置 | `BBox` | 图表区域大小 | -| crosshairs | 是否暂时指示线 | `boolean` | - | -| `crosshairs${StyleAttrs}` | 指示线的样式 | `number \| string` | - | -| `marker${StyleAttrs}` | marker 的样式 | `number \| string` | - | -| render | 自定义 tooltip 渲染函数 | `(event, options) => HTMLElement \| string` | - | -| sort | item 排序器 | `(d: TooltipItemValue) => any` | - | -| filter | item 筛选器 | `(d: TooltipItemValue) => any` | - | -| disableNative | 是否响应原生事件(pointerover 和 pointerout) | true | `boolean` | -| css | 设置容器的 [css](/examples/component/tooltip/#tooltip-style) 样式 | - | `Record` | +| 属性 | 描述 | 类型 | 默认值 | +| ------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------- | --------------------- | +| wait | 提示信息更新的时间间隔,单位为毫秒 | `number` | 50 | +| leading | 是否在时间间隔开始的时候更新提示信息 | `boolean` | true | +| trailing | 是否在时间间隔结束的时候更新提示信息 | `boolean` | false | +| shared | 相同 x 的元素是否共享 tooltip | `boolean` | false | +| series | 是否是系列元素的 tooltip | `boolean` | - | +| body | 是否展示 tooltip | `boolean` | true | +| marker | 是否展示 marker | `boolean` | true | +| groupName | 是否使用 groupName | `boolean` | true | +| position | tooltip 位置 | `TooltipPosition` | - | +| mount | tooltip 渲染的 dom 节点 | `string` \| `HTMLElement` | 图表容器 | +| bounding | tooltip 渲染的限制区域,超出会自动调整位置 | `BBox` | 图表区域大小 | +| crosshairs | 是否暂时指示线 | `boolean` | - | +| `crosshairs${StyleAttrs}` | 指示线的样式 | `number \| string` | - | +| `marker${StyleAttrs}` | marker 的样式 | `number \| string` | - | +| render | 自定义 tooltip 渲染函数 | `(event, options) => HTMLElement \| string` | - | +| sort | item 排序器 | `(d: TooltipItemValue) => any` | - | +| filter | item 筛选器 | `(d: TooltipItemValue) => any` | - | +| disableNative | 是否响应原生事件(pointerover 和 pointerout) | true | `boolean` | +| css | 设置容器的 [css](/examples/component/tooltip/#tooltip-style) 样式 | - | `Record` | +| closest | 在设置 series 为 true 的时候,是否拾取离鼠标最近元素的 tooltip item,一般在双轴图中使用 | false | `boolean` | ```ts type TooltipPosition = diff --git a/site/examples/general/dual/demo/line-bar.ts b/site/examples/general/dual/demo/line-bar.ts index 314bf7ab41..80c232f7d1 100644 --- a/site/examples/general/dual/demo/line-bar.ts +++ b/site/examples/general/dual/demo/line-bar.ts @@ -38,4 +38,6 @@ chart titleFill: '#fdae6b', }); +chart.interaction('tooltip', { closest: true }); + chart.render(); diff --git a/site/examples/general/dual/demo/multi-line.ts b/site/examples/general/dual/demo/multi-line.ts index dd35432dc5..efcf88ed0b 100644 --- a/site/examples/general/dual/demo/multi-line.ts +++ b/site/examples/general/dual/demo/multi-line.ts @@ -123,4 +123,6 @@ chart titleFill: '#91CC75', }); +chart.interaction('tooltip', { closest: true }); + chart.render(); diff --git a/site/examples/general/dual/demo/pareto.ts b/site/examples/general/dual/demo/pareto.ts index efef2f8d12..d7973eef77 100644 --- a/site/examples/general/dual/demo/pareto.ts +++ b/site/examples/general/dual/demo/pareto.ts @@ -78,4 +78,6 @@ chart .axis('y', null) .tooltip(null); +chart.interaction('tooltip', { closest: true }); + chart.render(); diff --git a/src/interaction/tooltip.ts b/src/interaction/tooltip.ts index 6af3434594..9df56f5f27 100644 --- a/src/interaction/tooltip.ts +++ b/src/interaction/tooltip.ts @@ -428,6 +428,7 @@ export function seriesTooltip( startY = 0, body = true, single = true, + closest = false, position, enterable, mount, @@ -470,6 +471,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 +509,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 +520,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) => {