实现业务需求时发现 tooltip 中呈现的内容比较多,当出现在边界时会出现一部分在可视范围以外。所幸 echarts 提供了一个 confine 配置给 tooltip,当为 true 时,可以强制使 tooltip 出现在 view 视图中。
接下来来看看源码中是怎样实现 confine 功能的。
首先可以看到,confine 是在
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // 是否约束 content 在 viewRect 中。默认 false 是为了兼容以前版本。 export default echarts.extendComponentModel({ type: 'tooltip', dependencies: ['axisPointer'], defaultOption: { // ... // 'trigger' only works on coordinate system. // 'item' | 'axis' | 'none' trigger: 'item', // 'click' | 'mousemove' | 'none' triggerOn: 'mousemove|click', // 是否约束 content 在 viewRect 中。默认 false 是为了兼容以前版本。 confine: false, // ... }, }); |
接下来,可以看到在同级目录下的
在这里,一共定义了三种 showTooltip 方法,对应不同的对象。分别是 _showAxisTooltip, _showComponentItemTooltip 和 _showSeriesItemTooltip . 我们只关注 series 中 item 的 tooltip, 至于 AxisTooltip 和 ComponentItemTooltip,在原理上基本一致。
梳理一番之后发现,在该类中,方法的调用链是
理清了思路,接下来我们来看代码是如何实现 confine 的过程。
弄清了执行顺序后,就很好理解 tooltip 的渲染过程了。在生命周期 render 函数中,调用了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // _initGlobalListener var tooltipModel = this._tooltipModel; var triggerOn = tooltipModel.get('triggerOn'); globalListener.register( 'itemTooltip', this._api, bind(function(currTrigger, e, dispatchAction) { // If 'none', it is not controlled by mouse totally. if (triggerOn !== 'none') { if (triggerOn.indexOf(currTrigger) >= 0) { this._tryShow(e, dispatchAction); } else if (currTrigger === 'leave') { this._hide(dispatchAction); } } }, this), ); |
tryShow 调用后, 我们可以看到这个方法实现非常直观,根据条件来判断显示 series、component 还是 axis 的 tooltip。我们重点关注_showSeriesItemTooltip.
走到_showSeriesItemTooltip,这个函数声明并计算了一系列的变量,都是为了 function _showTooltipContent 的参数做准备。我们可以看到
1 2 3 4 5 6 7 8 9 10 11 12 13 | this._showOrMove(tooltipModel, function() { this._showTooltipContent( tooltipModel, defaultHtml, params, asyncTicket, e.offsetX, e.offsetY, e.position, e.target, markers, ); }); |
结合 echarts tooltip 的文档和 tooltipModel 来看,我们可以传入一个配置参数 showDelay,如果 delay 大于 0 则 setTimeout,若干秒后执行回调函数,在这里则是显示 toolTip( _showTooltipContent);否则立即执行 callback。不过官方文档并不建议设置 delay。 所以我们可以认为_showOrMove 是个定时器,到了时间后显示 tooltip。_showOrMove 实现如下。
1 2 3 4 5 6 7 8 9 | //_showOrMove // showDelay is used in this case: tooltip.enterable is set // as true. User intent to move mouse into tooltip and click // something. `showDelay` makes it easyer to enter the content // but tooltip do not move immediately. var delay = tooltipModel.get('showDelay'); cb = zrUtil.bind(cb, this); clearTimeout(this._showTimout); delay > 0 ? (this._showTimout = setTimeout(cb, delay)) : cb(); |
回到_showTooltipContent, 在这个方法里我们知道了 echarts 如何兼容 formatter,传入 string 和 function 时不同的处理方法。通过 typeof 判断后,如果是 string, 则通过
关键代码如下, 实现逻辑在这里就不过多关注了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | // is string formatTpl /** * Template formatter * @param {string} tpl * @param {Array.<Object>|Object} paramsList * @param {boolean} [encode=false] * @return {string} */ export function formatTpl(tpl, paramsList, encode) { if (!zrUtil.isArray(paramsList)) { paramsList = [paramsList]; } var seriesLen = paramsList.length; if (!seriesLen) { return ''; } var $vars = paramsList[0].$vars || []; for (var i = 0; i < $vars.length; i++) { var alias = TPL_VAR_ALIAS[i]; tpl = tpl.replace(wrapVar(alias), wrapVar(alias, 0)); } for (var seriesIdx = 0; seriesIdx < seriesLen; seriesIdx++) { for (var k = 0; k < $vars.length; k++) { var val = paramsList[seriesIdx][$vars[k]]; tpl = tpl.replace( wrapVar(TPL_VAR_ALIAS[k], seriesIdx), encode ? encodeHTML(val) : val, ); } } return tpl; } |
1 2 3 4 | // is function, setContent setContent: function (content) { this.el.innerHTML = content == null ? '' : content; }, |
这里插一个题外话, HTML5 规范中表示
1 2 | name = "<script>alert('I am John in an annoying alert!')</script>"; el.innerHTML = name; // harmless in this case |
但是当不使用
1 2 | const name = "<img src='x' onerror='alert(1)'>"; el.innerHTML = name; // shows the alert |
基于这个原因,推荐使用
好了,终于生成了 content,和需要的坐标、参数等,这个时候调用了 _updatePosition. 在_updatePosition 中我们看到 echats 是如何去做当 position 字段传入 string, array 和 function 时的处理方法的。如果对这里感兴趣可以关注一下。 在这个方法的最后,我们看到了对 confine 的判断,如果为 true,则再次调用 confineTooltipPosition, 返回新的 x,y 坐标。然后将 content 移动到新的坐标位置。
1 2 3 4 5 6 7 8 9 10 11 12 | var viewWidth = this._api.getWidth(); var viewHeight = this._api.getHeight(); // ... if (tooltipModel.get('confine')) { var pos = confineTooltipPosition(x, y, content, viewWidth, viewHeight); x = pos[0]; y = pos[1]; } content.moveTo(x, y); |
这里看到 echarts 获取可视范围的高宽,是通过封装在内的 _api 内的方法获得。这里涉及到更底层的关于 echarts 调用 zrender 生成 root 绘图容器的过程,基本原理是先获取绘图区域实例,根据该实例再获取高宽。具体过程在此不作赘述。留个记录,有机会再来解析那一部分。具体代码可以参考
回到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | function confineTooltipPosition(x, y, content, viewWidth, viewHeight) { var size = content.getOuterSize(); var width = size.width; var height = size.height; x = Math.min(x + width, viewWidth) - width; y = Math.min(y + height, viewHeight) - height; x = Math.max(x, 0); y = Math.max(y, 0); return [x, y]; } getOuterSize: function () { var width = this.el.clientWidth; var height = this.el.clientHeight; // Consider browser compatibility. // IE8 does not support getComputedStyle. if (document.defaultView && document.defaultView.getComputedStyle) { var stl = document.defaultView.getComputedStyle(this.el); if (stl) { width += parseInt(stl.borderLeftWidth, 10) + parseInt(stl.borderRightWidth, 10); height += parseInt(stl.borderTopWidth, 10) + parseInt(stl.borderBottomWidth, 10); } } return {width: width, height: height}; } |
然后把 content 移动到新生成的坐标上,至此就完成了 confine 的功能。
最后说一个看代码的心得,平常在实现一些公共 sdk 时,经常需要暴露一些 api,有的时候看到直接定义的是一个 array,然后调用方使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | import * as zrUtil from 'zrender/src/core/util'; var echartsAPIList = [ 'getDom', 'getZr', 'getWidth', 'getHeight', 'getDevicePixelRatio', 'dispatchAction', 'isDisposed', 'on', 'off', 'getDataURL', 'getConnectedDataURL', 'getModel', 'getOption', 'getViewOfComponentModel', 'getViewOfSeriesModel', ]; // And `getCoordinateSystems` and `getComponentByElement` will be injected in echarts.js function ExtensionAPI(chartInstance) { zrUtil.each( echartsAPIList, function(name) { this[name] = zrUtil.bind(chartInstance[name], chartInstance); }, this, ); } export default ExtensionAPI; |