diff --git a/site/.dumi/global.ts b/site/.dumi/global.ts index ba34bdc8ee..6c1028cda0 100644 --- a/site/.dumi/global.ts +++ b/site/.dumi/global.ts @@ -1,6 +1,17 @@ require('./style.css'); require('./prism-one-light.css'); +// Polyfill crypto.randomUUID for non-secure contexts (e.g. HTTP on LAN IP). +if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') { + crypto.randomUUID = () => + '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + if (typeof window !== 'undefined' && window) { (window as any).g2 = extendG2(require('../../src')); (window as any).G2 = (window as any).g2; diff --git a/site/docs/manual/component/tooltip.en.md b/site/docs/manual/component/tooltip.en.md index e67c0fcf4c..1316f8d5f5 100644 --- a/site/docs/manual/component/tooltip.en.md +++ b/site/docs/manual/component/tooltip.en.md @@ -338,8 +338,17 @@ When configuring `tooltip.items` for composite charts, you need to configure nod | body | Whether to show tooltip | `boolean` | `true` | | | bounding | Control tooltip display boundary, position will be automatically adjusted when exceeded | `{ x: number, y: number, width: number, height: number }` | Chart area size | | | css | Set tooltip CSS styles | [css](#set-styles) | - | | -| crosshairs | Configure crosshair style | [crosshairs](#crosshairs) | See [crosshairs](#crosshairs) | | -| disableNative | Disable native pointerover and pointerout events, needs to be set to true when customizing tooltip interaction | `boolean` | `false` | | +| crosshairs | Configure crosshair style | [crosshairs](#crosshairs) | See [crosshairs](#crosshairs) | | +| crosshairsFollow | Whether crosshairs follow mouse position | `boolean` | `false` | | +| crosshairsXFollow | Whether horizontal crosshair follows mouse position | `boolean` | `false` | | +| crosshairsYFollow | Whether vertical crosshair follows mouse position | `boolean` | `false` | | +| crosshairsTag | Whether to show crosshair tags | `boolean` | `false` | | +| crosshairsXTag | Whether to show horizontal crosshair tag | `boolean` | `false` | | +| crosshairsYTag | Whether to show vertical crosshair tag | `boolean` | `false` | | +| crosshairsYTagPosition | Position of vertical crosshair tag | `'left' \| 'right'` | `'right'` | | +| crosshairsXTagFormatter | Formatter function for horizontal crosshair tag | `(value: any) => string` | - | | +| crosshairsYTagFormatter | Formatter function for vertical crosshair tag | `(value: any) => string` | - | | +| disableNative | Disable native pointerover and pointerout events, needs to be set to true when customizing tooltip interaction | `boolean` | `false` | | | enterable | Whether tooltip allows mouse entry | `boolean` | `false` | | | facet | Whether it's a facet chart tooltip | `boolean` | `false` | Facet composite charts | | filter | Item filter | `(d: TooltipItemValue) => any` | - | | @@ -400,6 +409,15 @@ Additionally, styles set through prefixes `crosshairsX` and `crosshairsY` have h | crosshairsYShadowOffsetX | Vertical crosshair shadow horizontal offset | number | - | | | crosshairsYShadowOffsetY | Vertical crosshair shadow vertical offset | number | - | | | crosshairsYCursor | Vertical crosshair cursor style | string | `default` | | +| crosshairsFollow | Whether crosshairs follow mouse position | boolean | `false` | | +| crosshairsXFollow | Whether horizontal crosshair follows mouse position | boolean | `false` | | +| crosshairsYFollow | Whether vertical crosshair follows mouse position | boolean | `false` | | +| crosshairsTag | Whether to show crosshair tags | boolean | `false` | | +| crosshairsXTag | Whether to show horizontal crosshair tag | boolean | `false` | | +| crosshairsYTag | Whether to show vertical crosshair tag | boolean | `false` | | +| crosshairsYTagPosition | Position of vertical crosshair tag | 'left' \| 'right' | `'right'` | | +| crosshairsXTagFormatter | Formatter function for horizontal crosshair tag | (value: any) => string | - | | +| crosshairsYTagFormatter | Formatter function for vertical crosshair tag | (value: any) => string | - | | ```js chart.options({ diff --git a/site/docs/manual/component/tooltip.zh.md b/site/docs/manual/component/tooltip.zh.md index 67577cedfa..1d25924339 100644 --- a/site/docs/manual/component/tooltip.zh.md +++ b/site/docs/manual/component/tooltip.zh.md @@ -338,8 +338,17 @@ chart.options({ | body | 是否展示 tooltip | `boolean` | `true` | | | bounding | 控制 tooltip 提示框的显示边界,超出会自动调整位置 | `{ x: number, y: number, width: number, height: number }` | 图表区域大小 | | | css | 设置 tooltip 的 css 样式 | [css](#设置样式) | - | | -| crosshairs | 配置十字辅助线 `crosshairs` 的样式 | [crosshairs](#crosshairs) | 详见 [crosshairs](#crosshairs) | | -| disableNative | 禁用原生的 pointerover 和 pointerout 事件,需要自定义 tooltip 交互的时候需要设置为 true | `boolean` | `false` | | +| crosshairs | 配置十字辅助线 `crosshairs` 的样式 | [crosshairs](#crosshairs) | 详见 [crosshairs](#crosshairs) | | +| crosshairsFollow | 十字辅助线是否跟随鼠标位置 | `boolean` | `false` | | +| crosshairsXFollow | 水平辅助线是否跟随鼠标位置 | `boolean` | `false` | | +| crosshairsYFollow | 垂直辅助线是否跟随鼠标位置 | `boolean` | `false` | | +| crosshairsTag | 是否显示十字辅助线标签 | `boolean` | `false` | | +| crosshairsXTag | 是否显示水平辅助线标签 | `boolean` | `false` | | +| crosshairsYTag | 是否显示垂直辅助线标签 | `boolean` | `false` | | +| crosshairsYTagPosition | 垂直辅助线标签位置 | `'left' \| 'right'` | `'right'` | | +| crosshairsXTagFormatter | 水平辅助线标签格式化函数 | `(value: any) => string` | - | | +| crosshairsYTagFormatter | 垂直辅助线标签格式化函数 | `(value: any) => string` | - | | +| disableNative | 禁用原生的 pointerover 和 pointerout 事件,需要自定义 tooltip 交互的时候需要设置为 true | `boolean` | `false` | | | enterable | tooltip 是否允许鼠标滑入 | `boolean` | `false` | | | facet | 是否是分面图的 tooltip | `boolean` | `false` | 分面复合图表 | | filter | item 筛选器 | `(d: TooltipItemValue) => any` | - | | @@ -400,6 +409,15 @@ chart.options({ | crosshairsYShadowOffsetX | 垂直方向辅助线阴影的水平方向偏移量 | number | - | | | crosshairsYShadowOffsetY | 垂直方向辅助线阴影的垂直方向偏移量 | number | - | | | crosshairsYCursor | 垂直方向辅助线的鼠标样式 | string | `default` | | +| crosshairsFollow | 十字辅助线是否跟随鼠标位置 | boolean | `false` | | +| crosshairsXFollow | 水平方向辅助线是否跟随鼠标位置 | boolean | `false` | | +| crosshairsYFollow | 垂直方向辅助线是否跟随鼠标位置 | boolean | `false` | | +| crosshairsTag | 是否显示十字辅助线标签 | boolean | `false` | | +| crosshairsXTag | 是否显示水平方向辅助线标签 | boolean | `false` | | +| crosshairsYTag | 是否显示垂直方向辅助线标签 | boolean | `false` | | +| crosshairsYTagPosition | 垂直方向辅助线标签位置 | 'left' \| 'right' | `'right'` | | +| crosshairsXTagFormatter | 水平方向辅助线标签格式化函数 | (value: any) => string | - | | +| crosshairsYTagFormatter | 垂直方向辅助线标签格式化函数 | (value: any) => string | - | | ```js chart.options({ diff --git a/site/examples/general/candlestick/demo/kline-crosshairs.ts b/site/examples/general/candlestick/demo/kline-crosshairs.ts new file mode 100644 index 0000000000..8ebff129d8 --- /dev/null +++ b/site/examples/general/candlestick/demo/kline-crosshairs.ts @@ -0,0 +1,268 @@ +import { Chart } from '@antv/g2'; + +const data = [ + { + time: '2015-11-19', + start: 8.18, + max: 8.33, + min: 7.98, + end: 8.32, + volumn: 1810, + money: 14723.56, + }, + { + time: '2015-11-18', + start: 8.37, + max: 8.6, + min: 8.03, + end: 8.09, + volumn: 2790.37, + money: 23309.19, + }, + { + time: '2015-11-17', + start: 8.7, + max: 8.78, + min: 8.32, + end: 8.37, + volumn: 3729.04, + money: 31709.71, + }, + { + time: '2015-11-16', + start: 8.18, + max: 8.69, + min: 8.05, + end: 8.62, + volumn: 3095.44, + money: 26100.69, + }, + { + time: '2015-11-13', + start: 8.01, + max: 8.75, + min: 7.97, + end: 8.41, + volumn: 5815.58, + money: 48562.37, + }, + { + time: '2015-11-12', + start: 7.76, + max: 8.18, + min: 7.61, + end: 8.15, + volumn: 4742.6, + money: 37565.36, + }, + { + time: '2015-11-11', + start: 7.55, + max: 7.81, + min: 7.49, + end: 7.8, + volumn: 3133.82, + money: 24065.42, + }, + { + time: '2015-11-10', + start: 7.5, + max: 7.68, + min: 7.44, + end: 7.57, + volumn: 2670.35, + money: 20210.58, + }, + { + time: '2015-11-09', + start: 7.65, + max: 7.66, + min: 7.3, + end: 7.58, + volumn: 2841.79, + money: 21344.36, + }, + { + time: '2015-11-06', + start: 7.52, + max: 7.71, + min: 7.48, + end: 7.64, + volumn: 2725.44, + money: 20721.51, + }, + { + time: '2015-11-05', + start: 7.48, + max: 7.57, + min: 7.29, + end: 7.48, + volumn: 3520.85, + money: 26140.83, + }, + { + time: '2015-11-04', + start: 7.01, + max: 7.5, + min: 7.01, + end: 7.46, + volumn: 3591.47, + money: 26285.52, + }, + { + time: '2015-11-03', + start: 7.1, + max: 7.17, + min: 6.82, + end: 7, + volumn: 2029.21, + money: 14202.33, + }, + { + time: '2015-11-02', + start: 7.09, + max: 7.44, + min: 6.93, + end: 7.17, + volumn: 3191.31, + money: 23205.11, + }, + { + time: '2015-10-30', + start: 6.98, + max: 7.27, + min: 6.84, + end: 7.18, + volumn: 3522.61, + money: 25083.44, + }, + { + time: '2015-10-29', + start: 6.94, + max: 7.2, + min: 6.8, + end: 7.05, + volumn: 2752.27, + money: 19328.44, + }, + { + time: '2015-10-28', + start: 7.01, + max: 7.14, + min: 6.8, + end: 6.85, + volumn: 2311.11, + money: 16137.32, + }, + { + time: '2015-10-27', + start: 6.91, + max: 7.31, + min: 6.48, + end: 7.18, + volumn: 3172.9, + money: 21827.3, + }, + { + time: '2015-10-26', + start: 6.9, + max: 7.08, + min: 6.87, + end: 6.95, + volumn: 2769.31, + money: 19337.44, + }, + { + time: '2015-10-23', + start: 6.71, + max: 6.85, + min: 6.58, + end: 6.79, + volumn: 2483.18, + money: 16714.31, + }, + { + time: '2015-10-22', + start: 6.38, + max: 6.67, + min: 6.34, + end: 6.65, + volumn: 2225.88, + money: 14465.56, + }, +]; + +const chart = new Chart({ + container: 'container', + autoFit: true, +}); + +chart + .data(data) + .encode('x', 'time') + .encode('color', (d) => { + const trend = Math.sign(d.start - d.end); + return trend > 0 ? '下跌' : trend === 0 ? '不变' : '上涨'; + }) + .scale('x', { + compare: (a, b) => new Date(a).getTime() - new Date(b).getTime(), + }) + .scale('color', { + domain: ['下跌', '不变', '上涨'], + range: ['#4daf4a', '#999999', '#e41a1c'], + }) + .interaction('tooltip', { + shared: true, + crosshairs: true, + // 十字光标跟随鼠标移动(开启此项会自动启用 seriesTooltip 模式) + crosshairsFollow: true, + crosshairsX: true, + crosshairsY: true, + crosshairsStroke: '#999999', + crosshairsLineDash: [4, 4], + // 显示坐标标签 + crosshairsTag: true, + // 自定义X轴标签格式化 + crosshairsXTagFormatter: (value) => `${value}`, + // 自定义Y轴标签格式化(保留两位小数) + crosshairsYTagFormatter: (value) => Number(value).toFixed(2), + crosshairsTagBackgroundFill: '#999999', + crosshairsTagFill: '#fff', + crosshairsTagFontSize: 12, + crosshairsTagPadding: [2, 6], + }); + +chart + .link() + .encode('y', ['min', 'max']) + .tooltip({ + title: 'time', + items: [ + { field: 'start', name: '开盘价' }, + { field: 'end', name: '收盘价' }, + { field: 'min', name: '最低价' }, + { field: 'max', name: '最高价' }, + ], + }); + +chart + .interval() + .encode('y', ['start', 'end']) + .style('fillOpacity', 1) + .style('stroke', (d) => { + if (d.start === d.end) return '#999999'; + }) + .axis('y', { + title: false, + }) + .tooltip({ + title: 'time', + items: [ + { field: 'start', name: '开盘价' }, + { field: 'end', name: '收盘价' }, + { field: 'min', name: '最低价' }, + { field: 'max', name: '最高价' }, + ], + }); + +chart.render(); diff --git a/site/examples/general/candlestick/demo/meta.json b/site/examples/general/candlestick/demo/meta.json index dd3ee6f7d7..2603b886e7 100644 --- a/site/examples/general/candlestick/demo/meta.json +++ b/site/examples/general/candlestick/demo/meta.json @@ -20,6 +20,14 @@ }, "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*ehjWTYLKU_wAAAAAAAAAAAAADmJ7AQ/original" }, + { + "filename": "kline-crosshairs.ts", + "title": { + "zh": "K线图十字光标", + "en": "K-line Crosshairs" + }, + "screenshot": "" + }, { "filename": "k-and-column.ts", "title": { diff --git a/src/interaction/tooltip.ts b/src/interaction/tooltip.ts index 12142c0158..11d72c051e 100644 --- a/src/interaction/tooltip.ts +++ b/src/interaction/tooltip.ts @@ -1,4 +1,12 @@ -import { Circle, DisplayObject, IElement, Line } from '@antv/g'; +import { + Circle, + DisplayObject, + Group, + IElement, + Line, + Rect, + Text, +} from '@antv/g'; import { sort, group, mean, bisector, minIndex } from '@antv/vendor/d3-array'; import { deepMix, lowerFirst, set, throttle, last, isNumber } from '@antv/util'; import { Tooltip as TooltipComponent } from '@antv/component'; @@ -185,6 +193,8 @@ function hideTooltip({ } hideRuleY(root); hideRuleX(root); + hideTagX(root); + hideTagY(root); hideMarker(root); } @@ -199,6 +209,8 @@ function destroyTooltip({ root, single }) { } hideRuleY(root); hideRuleX(root); + hideTagX(root); + hideTagY(root); hideMarker(root); } @@ -347,6 +359,7 @@ function updateRuleX( polar, insetLeft, insetTop, + follow = false, ...rest }, ) { @@ -394,7 +407,8 @@ function updateRuleX( return points[minDistIndex]; }; - const target = minDistPoint(mouse, points); + const target = + follow || points.length === 0 ? mouse : minDistPoint(mouse, points); const pointsOf = () => { if (transposed) @@ -435,6 +449,7 @@ function updateRuleX( function updateRuleY( root, points, + mouse, { plotWidth, plotHeight, @@ -446,6 +461,7 @@ function updateRuleY( polar, insetLeft, insetTop, + follow = false, ...rest }, ) { @@ -458,8 +474,8 @@ function updateRuleY( const Y = points.map((p) => p[1]); const X = points.map((p) => p[0]); - const y = mean(Y); - const x = mean(X); + const y = follow || Y.length === 0 ? mouse[1] : mean(Y); + const x = follow || X.length === 0 ? mouse[0] : mean(X); const pointsOf = () => { if (polar) { @@ -490,8 +506,8 @@ function updateRuleY( root.appendChild(line); return line; }; - // Only update rule with defined series elements. - if (X.length > 0) { + // In follow mode, allow rendering even if there are no points. + if (X.length > 0 || follow) { const ruleY = root.ruleY || createLine(); ruleY.style.x1 = x1; ruleY.style.x2 = x2; @@ -515,6 +531,145 @@ function hideRuleX(root) { } } +function hideTagX(root) { + if (root.crosshairsTagX) { + root.crosshairsTagX.remove(); + root.crosshairsTagX = undefined; + } +} + +function hideTagY(root) { + if (root.crosshairsTagY) { + root.crosshairsTagY.remove(); + root.crosshairsTagY = undefined; + } +} + +function showTag(root, key: 'crosshairsTagX' | 'crosshairsTagY') { + const group = root[key] || new Group(); + if (!root[key]) { + const background = new Rect(); + const text = new Text(); + group.appendChild(background); + group.appendChild(text); + const container = (root.parentNode || root) as DisplayObject; + container.appendChild(group); + root[key] = group; + } + return group; +} + +function normalizeTagStyle(style: Record = {}) { + const { + formatter, + position = 'right', + offsetX = 0, + offsetY = 0, + padding = [2, 6], + textAlign = 'left', + textBaseline = 'top', + ...rest + } = style; + const [paddingY, paddingX] = padding; + const background = subObject(rest, 'background'); + const textOnlyStyle = Object.fromEntries( + Object.entries(rest).filter(([key]) => !key.startsWith('background')), + ); + const textStyle = { + ...textOnlyStyle, + textAlign, + textBaseline, + }; + return { + formatter, + position, + offsetX, + offsetY, + paddingX, + paddingY, + textStyle, + backgroundStyle: background, + }; +} + +function updateTag( + root, + key: 'crosshairsTagX' | 'crosshairsTagY', + value: any, + x: number, + y: number, + axis: 'x' | 'y', + tagStyle: Record = {}, + boundary: { left: number; right: number; top: number; bottom: number }, +) { + if (!defined(value)) { + if (key === 'crosshairsTagX') hideTagX(root); + if (key === 'crosshairsTagY') hideTagY(root); + return; + } + const { + formatter, + position, + offsetX, + offsetY, + paddingX, + paddingY, + textStyle, + backgroundStyle, + } = normalizeTagStyle(tagStyle); + const group = showTag(root, key); + const [background, text] = group.childNodes as [Rect, Text]; + const textValue = formatter ? formatter(value) : `${value}`; + text.attr({ + text: textValue, + x: 0, + y: 0, + fill: '#fff', + fontSize: 10, + fontFamily: 'sans-serif', + ...textStyle, + }); + const bounds = text.getLocalBounds(); + const { + min: [minX, minY], + max: [maxX, maxY], + } = bounds; + const width = maxX - minX; + const height = maxY - minY; + const tagWidth = width + paddingX * 2; + const tagHeight = height + paddingY * 2; + background.attr({ + x: 0, + y: 0, + width: tagWidth, + height: tagHeight, + radius: 2, + fill: '#1b1e23', + ...backgroundStyle, + }); + text.attr({ + x: paddingX - minX, + y: paddingY - minY, + }); + const px = + axis === 'x' + ? x - tagWidth / 2 + offsetX + : position === 'left' + ? x - tagWidth - 6 + offsetX + : x + 6 + offsetX; + const py = + axis === 'x' ? y - tagHeight - 6 + offsetY : y - tagHeight / 2 + offsetY; + const clampedX = Math.max( + boundary.left, + Math.min(boundary.right - tagWidth, px), + ); + const clampedY = Math.max( + boundary.top, + Math.min(boundary.bottom - tagHeight, py), + ); + group.style.transform = `translate(${clampedX}, ${clampedY})`; +} + function updateMarker(root, { data, style, theme }) { if (root.markers) root.markers.forEach((d) => d.remove()); const { type = '' } = style; @@ -933,8 +1088,17 @@ export function seriesTooltip( scale, coordinate, crosshairs, + crosshairsTag, crosshairsX, crosshairsY, + crosshairsFollow, + crosshairsXFollow, + crosshairsYFollow, + crosshairsXTag, + crosshairsYTag, + crosshairsYTagPosition, + crosshairsXTagFormatter, + crosshairsYTagFormatter, render, groupName, emitter, @@ -978,6 +1142,10 @@ export function seriesTooltip( if (clickLock && root.getAttribute(LOCKED_SYMBOL)) return; const mouse = mousePosition(root, event); if (!mouse) return; + const focus = [mouse[0] - startX, mouse[1] - startY]; + const focusX = Math.max(0, Math.min(plotWidth, focus[0])); + const focusY = Math.max(0, Math.min(plotHeight, focus[1])); + const clampedFocus = [focusX, focusY]; const bbox = bboxOf(root); const x = bbox.min[0]; const y = bbox.min[1]; @@ -1031,16 +1199,21 @@ export function seriesTooltip( }); } + const followX = maybeValue(crosshairsXFollow, crosshairsFollow); + const followY = maybeValue(crosshairsYFollow, crosshairsFollow); + if (crosshairs || crosshairsX || crosshairsY) { const ruleStyle = subObject(style, 'crosshairs'); const ruleStyleX = { ...ruleStyle, ...subObject(style, 'crosshairsX'), + follow: followX, }; const ruleStyleY = { ...ruleStyle, ...subObject(style, 'crosshairsY'), + follow: followY, }; const points = filteredSeriesData.map((d) => d[1]); @@ -1061,7 +1234,7 @@ export function seriesTooltip( } if (crosshairsY) { - updateRuleY(root, points, { + updateRuleY(root, points, mouse, { ...ruleStyleY, plotWidth, plotHeight, @@ -1077,6 +1250,92 @@ export function seriesTooltip( } } + const [invertedX, invertedY] = coordinate.invert(clampedFocus); + const xOfSeries = filteredSeriesData[0]?.[0].x; + const yOfSeries = filteredSeriesData[0]?.[0].y; + const xValue = invert( + scale.x, + followY ? invertedX : xOfSeries ?? invertedX, + true, + ); + const yValue = invert( + scale.y, + followX ? invertedY : yOfSeries ?? invertedY, + true, + ); + const tagStyle = subObject(style, 'crosshairsTag'); + const tagStyleX: Record = { + ...tagStyle, + ...subObject(style, 'crosshairsXTag'), + formatter: crosshairsXTagFormatter, + }; + const tagStyleY: Record = { + ...tagStyle, + ...subObject(style, 'crosshairsYTag'), + formatter: crosshairsYTagFormatter, + }; + const scaleYGuide = scale.y?.getOptions?.()?.guide; + const inferredYTagPostion = + scaleYGuide?.position === 'right' ? 'right' : 'left'; + const yTagPosition = maybeValue( + crosshairsYTagPosition, + tagStyleY.position ?? inferredYTagPostion, + ); + const rootBounds = bboxOf(root); + const tagContainer = (root.parentNode || root) as DisplayObject; + const containerBounds = bboxOf(tagContainer); + const rootMinX = rootBounds.min[0]; + const rootMinY = rootBounds.min[1]; + const containerMinX = containerBounds.min[0]; + const containerMinY = containerBounds.min[1]; + const containerWidth = containerBounds.max[0] - containerMinX; + const containerHeight = containerBounds.max[1] - containerMinY; + const useTagX = maybeValue(crosshairsXTag, crosshairsTag); + const useTagY = maybeValue(crosshairsYTag, crosshairsTag); + if (useTagX) { + updateTag( + root, + 'crosshairsTagX', + xValue, + rootMinX + startX + focusX - containerMinX, + rootMinY + startY + plotHeight - containerMinY, + 'x', + tagStyleX, + { + left: 0, + right: containerWidth, + top: 0, + bottom: containerHeight, + }, + ); + } else { + hideTagX(root); + } + if (useTagY) { + updateTag( + root, + 'crosshairsTagY', + yValue, + rootMinX + + (yTagPosition === 'left' ? startX : startX + plotWidth) - + containerMinX, + rootMinY + startY + focusY - containerMinY, + 'y', + { + ...tagStyleY, + position: yTagPosition, + }, + { + left: 0, + right: containerWidth, + top: 0, + bottom: containerHeight, + }, + ); + } else { + hideTagY(root); + } + if (marker) { const markerStyles = subObject(style, 'marker'); updateMarker(root, { @@ -1089,7 +1348,7 @@ export function seriesTooltip( // X in focus may related multiple points when dataset is large, // so we need to find the first x to show tooltip. const firstX = filteredSeriesData[0]?.[0].x; - const transformedX = firstX ?? abstractX(focus); + const transformedX = firstX ?? abstractX(clampedFocus); emitter.emit('tooltip:show', { ...event, @@ -1416,22 +1675,27 @@ export function Tooltip(options) { const crosshairsSetting = maybeValue(crosshairs, defaultShowCrosshairs); if (rest.clickLock && !facet) plotArea.setAttribute(LOCKED_SYMBOL, false); // For non-facet and series tooltip. - if (isSeries && hasSeries(markState) && !facet) { - return seriesTooltip(plotArea, { - ...rest, - theme, - elements: selectG2Elements, - scale, - coordinate, - crosshairs: crosshairsSetting, - // the crosshairsX settings level: crosshairsX > crosshairs > false - // it means crosshairsX default is false - crosshairsX: maybeValue(maybeValue(crosshairsX, crosshairs), false), - // crosshairsY default depend on the crossharisSettings - crosshairsY: maybeValue(crosshairsY, crosshairsSetting), - item, - emitter, - }); + // Enter when: + // 1. isSeries && hasSeries(markState) - original logic for series marks + // 2. crosshairsFollow is enabled - needs seriesTooltip for follow functionality + if ((isSeries && hasSeries(markState)) || rest.crosshairsFollow) { + if (!facet) { + return seriesTooltip(plotArea, { + ...rest, + theme, + elements: selectG2Elements, + scale, + coordinate, + crosshairs: crosshairsSetting, + // the crosshairsX settings level: crosshairsX > crosshairs > false + // it means crosshairsX default is false + crosshairsX: maybeValue(maybeValue(crosshairsX, crosshairs), false), + // crosshairsY default depend on the crossharisSettings + crosshairsY: maybeValue(crosshairsY, crosshairsSetting), + item, + emitter, + }); + } } // For facet and series tooltip. diff --git a/src/spec/interaction.ts b/src/spec/interaction.ts index a9d8863070..46b16aae94 100644 --- a/src/spec/interaction.ts +++ b/src/spec/interaction.ts @@ -203,6 +203,15 @@ export type TooltipInteraction = { facet?: boolean; body?: boolean; crosshairs?: boolean; + crosshairsFollow?: boolean; + crosshairsXFollow?: boolean; + crosshairsYFollow?: boolean; + crosshairsTag?: boolean; + crosshairsXTag?: boolean; + crosshairsYTag?: boolean; + crosshairsYTagPosition?: 'left' | 'right'; + crosshairsXTagFormatter?: (value: any) => string; + crosshairsYTagFormatter?: (value: any) => string; marker?: boolean; groupName?: boolean; disableNative?: boolean;