/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { bind, each, clone, trim, isString, isFunction, isArray, isObject, extend } from 'zrender/src/core/util'; import env from 'zrender/src/core/env'; import TooltipHTMLContent from './TooltipHTMLContent'; import TooltipRichContent from './TooltipRichContent'; import { convertToColorString, formatTpl, TooltipMarker } from '../../util/format'; import { parsePercent } from '../../util/number'; import { Rect } from '../../util/graphic'; import findPointFromSeries from '../axisPointer/findPointFromSeries'; import { getLayoutRect } from '../../util/layout'; import Model from '../../model/Model'; import * as globalListener from '../axisPointer/globalListener'; import * as axisHelper from '../../coord/axisHelper'; import * as axisPointerViewHelper from '../axisPointer/viewHelper'; import { getTooltipRenderMode, preParseFinder, queryReferringComponents } from '../../util/model'; import ComponentView from '../../view/Component'; import { format as timeFormat } from '../../util/time'; import { HorizontalAlign, VerticalAlign, ZRRectLike, BoxLayoutOptionMixin, CallbackDataParams, TooltipRenderMode, ECElement, CommonTooltipOption, ZRColor, ComponentMainType, ComponentItemTooltipOption } from '../../util/types'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import TooltipModel, { TooltipOption } from './TooltipModel'; import Element from 'zrender/src/Element'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; import { ECData, getECData } from '../../util/innerStore'; import { shouldTooltipConfine } from './helper'; import { DataByCoordSys, DataByAxis } from '../axisPointer/axisTrigger'; import { normalizeTooltipFormatResult } from '../../model/mixin/dataFormat'; import { createTooltipMarkup, buildTooltipMarkup, TooltipMarkupStyleCreator } from './tooltipMarkup'; import { findEventDispatcher } from '../../util/event'; import { clear, createOrUpdate } from '../../util/throttle'; const proxyRect = new Rect({ shape: { x: -1, y: -1, width: 2, height: 2 } }); interface DataIndex { seriesIndex: number dataIndex: number dataIndexInside: number } interface ShowTipPayload { type?: 'showTip' from?: string // Type 1 tooltip?: ECData['tooltipConfig']['option'] // Type 2 dataByCoordSys?: DataByCoordSys[] tooltipOption?: CommonTooltipOption // Type 3 seriesIndex?: number dataIndex?: number // Type 4 name?: string // target item name that enable tooltip. // legendIndex: 0, // toolboxId: 'some_id', // geoName: 'some_name', x?: number y?: number position?: TooltipOption['position'] dispatchAction?: ExtensionAPI['dispatchAction'] } interface HideTipPayload { type?: 'hideTip' from?: string dispatchAction?: ExtensionAPI['dispatchAction'] } interface TryShowParams { target?: ECElement, offsetX?: number offsetY?: number /** * Used for axis trigger. */ dataByCoordSys?: DataByCoordSys[] tooltipOption?: ComponentItemTooltipOption position?: TooltipOption['position'] /** * If `position` is not set in payload nor option, use it. */ positionDefault?: TooltipOption['position'] } type TooltipCallbackDataParams = CallbackDataParams & { axisDim?: string axisIndex?: number axisType?: string axisId?: string // TODO: TYPE Value type axisValue?: string | number axisValueLabel?: string marker?: TooltipMarker }; class TooltipView extends ComponentView { static type = 'tooltip' as const; type = TooltipView.type; private _renderMode: TooltipRenderMode; private _tooltipModel: TooltipModel; private _ecModel: GlobalModel; private _api: ExtensionAPI; private _tooltipContent: TooltipHTMLContent | TooltipRichContent; private _refreshUpdateTimeout: number; private _lastX: number; private _lastY: number; private _ticket: string; private _showTimout: number; private _lastDataByCoordSys: DataByCoordSys[]; private _cbParamsList: TooltipCallbackDataParams[]; init(ecModel: GlobalModel, api: ExtensionAPI) { if (env.node || !api.getDom()) { return; } const tooltipModel = ecModel.getComponent('tooltip') as TooltipModel; const renderMode = this._renderMode = getTooltipRenderMode(tooltipModel.get('renderMode')); this._tooltipContent = renderMode === 'richText' ? new TooltipRichContent(api) : new TooltipHTMLContent(api.getDom(), api, { appendToBody: tooltipModel.get('appendToBody', true) }); } render( tooltipModel: TooltipModel, ecModel: GlobalModel, api: ExtensionAPI ) { if (env.node || !api.getDom()) { return; } // Reset this.group.removeAll(); this._tooltipModel = tooltipModel; this._ecModel = ecModel; this._api = api; const tooltipContent = this._tooltipContent; tooltipContent.update(tooltipModel); tooltipContent.setEnterable(tooltipModel.get('enterable')); this._initGlobalListener(); this._keepShow(); // PENDING // `mousemove` event will be triggered very frequently when the mouse moves fast, // which causes that the `updatePosition` function was also called frequently. // In Chrome with devtools open and Firefox, tooltip looks laggy and shakes. See #14695 #16101 // To avoid frequent triggering, // consider throttling it in 50ms when transition is enabled if (this._renderMode !== 'richText' && tooltipModel.get('transitionDuration')) { createOrUpdate(this, '_updatePosition', 50, 'fixRate'); } else { clear(this, '_updatePosition'); } } private _initGlobalListener() { const tooltipModel = this._tooltipModel; const 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) ); } private _keepShow() { const tooltipModel = this._tooltipModel; const ecModel = this._ecModel; const api = this._api; const triggerOn = tooltipModel.get('triggerOn'); // Try to keep the tooltip show when refreshing if (this._lastX != null && this._lastY != null // When user is willing to control tooltip totally using API, // self.manuallyShowTip({x, y}) might cause tooltip hide, // which is not expected. && triggerOn !== 'none' && triggerOn !== 'click' ) { const self = this; clearTimeout(this._refreshUpdateTimeout); this._refreshUpdateTimeout = setTimeout(function () { // Show tip next tick after other charts are rendered // In case highlight action has wrong result // FIXME !api.isDisposed() && self.manuallyShowTip(tooltipModel, ecModel, api, { x: self._lastX, y: self._lastY, dataByCoordSys: self._lastDataByCoordSys }); }) as any; } } /** * Show tip manually by * dispatchAction({ * type: 'showTip', * x: 10, * y: 10 * }); * Or * dispatchAction({ * type: 'showTip', * seriesIndex: 0, * dataIndex or dataIndexInside or name * }); * * TODO Batch */ manuallyShowTip( tooltipModel: TooltipModel, ecModel: GlobalModel, api: ExtensionAPI, payload: ShowTipPayload ) { if (payload.from === this.uid || env.node || !api.getDom()) { return; } const dispatchAction = makeDispatchAction(payload, api); // Reset ticket this._ticket = ''; // When triggered from axisPointer. const dataByCoordSys = payload.dataByCoordSys; const cmptRef = findComponentReference(payload, ecModel, api); if (cmptRef) { const rect = cmptRef.el.getBoundingRect().clone(); rect.applyTransform(cmptRef.el.transform); this._tryShow({ offsetX: rect.x + rect.width / 2, offsetY: rect.y + rect.height / 2, target: cmptRef.el, position: payload.position, // When manully trigger, the mouse is not on the el, so we'd better to // position tooltip on the bottom of the el and display arrow is possible. positionDefault: 'bottom' }, dispatchAction); } else if (payload.tooltip && payload.x != null && payload.y != null) { const el = proxyRect as unknown as ECElement; el.x = payload.x; el.y = payload.y; el.update(); getECData(el).tooltipConfig = { name: null, option: payload.tooltip }; // Manually show tooltip while view is not using zrender elements. this._tryShow({ offsetX: payload.x, offsetY: payload.y, target: el }, dispatchAction); } else if (dataByCoordSys) { this._tryShow({ offsetX: payload.x, offsetY: payload.y, position: payload.position, dataByCoordSys: dataByCoordSys, tooltipOption: payload.tooltipOption }, dispatchAction); } else if (payload.seriesIndex != null) { if (this._manuallyAxisShowTip(tooltipModel, ecModel, api, payload)) { return; } const pointInfo = findPointFromSeries(payload, ecModel); const cx = pointInfo.point[0]; const cy = pointInfo.point[1]; if (cx != null && cy != null) { this._tryShow({ offsetX: cx, offsetY: cy, target: pointInfo.el, position: payload.position, // When manully trigger, the mouse is not on the el, so we'd better to // position tooltip on the bottom of the el and display arrow is possible. positionDefault: 'bottom' }, dispatchAction); } } else if (payload.x != null && payload.y != null) { // FIXME // should wrap dispatchAction like `axisPointer/globalListener` ? api.dispatchAction({ type: 'updateAxisPointer', x: payload.x, y: payload.y }); this._tryShow({ offsetX: payload.x, offsetY: payload.y, position: payload.position, target: api.getZr().findHover(payload.x, payload.y).target }, dispatchAction); } } manuallyHideTip( tooltipModel: TooltipModel, ecModel: GlobalModel, api: ExtensionAPI, payload: HideTipPayload ) { const tooltipContent = this._tooltipContent; if (this._tooltipModel) { tooltipContent.hideLater(this._tooltipModel.get('hideDelay')); } this._lastX = this._lastY = this._lastDataByCoordSys = null; if (payload.from !== this.uid) { this._hide(makeDispatchAction(payload, api)); } } // Be compatible with previous design, that is, when tooltip.type is 'axis' and // dispatchAction 'showTip' with seriesIndex and dataIndex will trigger axis pointer // and tooltip. private _manuallyAxisShowTip( tooltipModel: TooltipModel, ecModel: GlobalModel, api: ExtensionAPI, payload: ShowTipPayload ) { const seriesIndex = payload.seriesIndex; const dataIndex = payload.dataIndex; // @ts-ignore const coordSysAxesInfo = ecModel.getComponent('axisPointer').coordSysAxesInfo; if (seriesIndex == null || dataIndex == null || coordSysAxesInfo == null) { return; } const seriesModel = ecModel.getSeriesByIndex(seriesIndex); if (!seriesModel) { return; } const data = seriesModel.getData(); const tooltipCascadedModel = buildTooltipModel([ data.getItemModel(dataIndex), seriesModel as Model, (seriesModel.coordinateSystem || {}).model as Model ], this._tooltipModel); if (tooltipCascadedModel.get('trigger') !== 'axis') { return; } api.dispatchAction({ type: 'updateAxisPointer', seriesIndex: seriesIndex, dataIndex: dataIndex, position: payload.position }); return true; } private _tryShow( e: TryShowParams, dispatchAction: ExtensionAPI['dispatchAction'] ) { const el = e.target; const tooltipModel = this._tooltipModel; if (!tooltipModel) { return; } // Save mouse x, mouse y. So we can try to keep showing the tip if chart is refreshed this._lastX = e.offsetX; this._lastY = e.offsetY; const dataByCoordSys = e.dataByCoordSys; if (dataByCoordSys && dataByCoordSys.length) { this._showAxisTooltip(dataByCoordSys, e); } else if (el) { this._lastDataByCoordSys = null; let seriesDispatcher: Element; let cmptDispatcher: Element; findEventDispatcher(el, (target) => { // Always show item tooltip if mouse is on the element with dataIndex if (getECData(target).dataIndex != null) { seriesDispatcher = target; return true; } // Tooltip provided directly. Like legend. if (getECData(target).tooltipConfig != null) { cmptDispatcher = target; return true; } }, true); if (seriesDispatcher) { this._showSeriesItemTooltip(e, seriesDispatcher, dispatchAction); } else if (cmptDispatcher) { this._showComponentItemTooltip(e, cmptDispatcher, dispatchAction); } else { this._hide(dispatchAction); } } else { this._lastDataByCoordSys = null; this._hide(dispatchAction); } } private _showOrMove( tooltipModel: Model, cb: () => void ) { // 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 easier to enter the content // but tooltip do not move immediately. const delay = tooltipModel.get('showDelay'); cb = bind(cb, this); clearTimeout(this._showTimout); delay > 0 ? (this._showTimout = setTimeout(cb, delay) as any) : cb(); } private _showAxisTooltip( dataByCoordSys: DataByCoordSys[], e: TryShowParams ) { const ecModel = this._ecModel; const globalTooltipModel = this._tooltipModel; const point = [e.offsetX, e.offsetY]; const singleTooltipModel = buildTooltipModel( [e.tooltipOption], globalTooltipModel ); const renderMode = this._renderMode; const cbParamsList: TooltipCallbackDataParams[] = []; const articleMarkup = createTooltipMarkup('section', { blocks: [], noHeader: true }); // Only for legacy: `Serise['formatTooltip']` returns a string. const markupTextArrLegacy: string[] = []; const markupStyleCreator = new TooltipMarkupStyleCreator(); each(dataByCoordSys, function (itemCoordSys) { each(itemCoordSys.dataByAxis, function (axisItem) { const axisModel = ecModel.getComponent(axisItem.axisDim + 'Axis', axisItem.axisIndex) as AxisBaseModel; const axisValue = axisItem.value; if (!axisModel || axisValue == null) { return; } const axisValueLabel = axisPointerViewHelper.getValueLabel( axisValue, axisModel.axis, ecModel, axisItem.seriesDataIndices, axisItem.valueLabelOpt ); const axisSectionMarkup = createTooltipMarkup('section', { header: axisValueLabel, noHeader: !trim(axisValueLabel), sortBlocks: true, blocks: [] }); articleMarkup.blocks.push(axisSectionMarkup); each(axisItem.seriesDataIndices, function (idxItem) { const series = ecModel.getSeriesByIndex(idxItem.seriesIndex); const dataIndex = idxItem.dataIndexInside; const cbParams = series.getDataParams(dataIndex) as TooltipCallbackDataParams; // Can't find data. if (cbParams.dataIndex < 0) { return; } cbParams.axisDim = axisItem.axisDim; cbParams.axisIndex = axisItem.axisIndex; cbParams.axisType = axisItem.axisType; cbParams.axisId = axisItem.axisId; cbParams.axisValue = axisHelper.getAxisRawValue( axisModel.axis, { value: axisValue as number } ); cbParams.axisValueLabel = axisValueLabel; // Pre-create marker style for makers. Users can assemble richText // text in `formatter` callback and use those markers style. cbParams.marker = markupStyleCreator.makeTooltipMarker( 'item', convertToColorString(cbParams.color), renderMode ); const seriesTooltipResult = normalizeTooltipFormatResult( series.formatTooltip(dataIndex, true, null) ); const frag = seriesTooltipResult.frag; if (frag) { const valueFormatter = buildTooltipModel( [series as Model], globalTooltipModel ).get('valueFormatter'); axisSectionMarkup.blocks.push(valueFormatter ? extend({ valueFormatter }, frag) : frag); } if (seriesTooltipResult.text) { markupTextArrLegacy.push(seriesTooltipResult.text); } cbParamsList.push(cbParams); }); }); }); // In most cases, the second axis is displays upper on the first one. // So we reverse it to look better. articleMarkup.blocks.reverse(); markupTextArrLegacy.reverse(); const positionExpr = e.position; const orderMode = singleTooltipModel.get('order'); const builtMarkupText = buildTooltipMarkup( articleMarkup, markupStyleCreator, renderMode, orderMode, ecModel.get('useUTC'), singleTooltipModel.get('textStyle') ); builtMarkupText && markupTextArrLegacy.unshift(builtMarkupText); const blockBreak = renderMode === 'richText' ? '\n\n' : '
'; const allMarkupText = markupTextArrLegacy.join(blockBreak); this._showOrMove(singleTooltipModel, function (this: TooltipView) { if (this._updateContentNotChangedOnAxis(dataByCoordSys, cbParamsList)) { this._updatePosition( singleTooltipModel, positionExpr, point[0], point[1], this._tooltipContent, cbParamsList ); } else { this._showTooltipContent( singleTooltipModel, allMarkupText, cbParamsList, Math.random() + '', point[0], point[1], positionExpr, null, markupStyleCreator ); } }); // Do not trigger events here, because this branch only be entered // from dispatchAction. } private _showSeriesItemTooltip( e: TryShowParams, dispatcher: ECElement, dispatchAction: ExtensionAPI['dispatchAction'] ) { const ecModel = this._ecModel; const ecData = getECData(dispatcher); // Use dataModel in element if possible // Used when mouseover on a element like markPoint or edge // In which case, the data is not main data in series. const seriesIndex = ecData.seriesIndex; const seriesModel = ecModel.getSeriesByIndex(seriesIndex); // For example, graph link. const dataModel = ecData.dataModel || seriesModel; const dataIndex = ecData.dataIndex; const dataType = ecData.dataType; const data = dataModel.getData(dataType); const renderMode = this._renderMode; const positionDefault = e.positionDefault; const tooltipModel = buildTooltipModel( [ data.getItemModel(dataIndex), dataModel, seriesModel && (seriesModel.coordinateSystem || {}).model as Model ], this._tooltipModel, positionDefault ? { position: positionDefault } : null ); const tooltipTrigger = tooltipModel.get('trigger'); if (tooltipTrigger != null && tooltipTrigger !== 'item') { return; } const params = dataModel.getDataParams(dataIndex, dataType); const markupStyleCreator = new TooltipMarkupStyleCreator(); // Pre-create marker style for makers. Users can assemble richText // text in `formatter` callback and use those markers style. params.marker = markupStyleCreator.makeTooltipMarker( 'item', convertToColorString(params.color), renderMode ); const seriesTooltipResult = normalizeTooltipFormatResult( dataModel.formatTooltip(dataIndex, false, dataType) ); const orderMode = tooltipModel.get('order'); const valueFormatter = tooltipModel.get('valueFormatter'); const frag = seriesTooltipResult.frag; const markupText = frag ? buildTooltipMarkup( valueFormatter ? extend({ valueFormatter }, frag) : frag, markupStyleCreator, renderMode, orderMode, ecModel.get('useUTC'), tooltipModel.get('textStyle') ) : seriesTooltipResult.text; const asyncTicket = 'item_' + dataModel.name + '_' + dataIndex; this._showOrMove(tooltipModel, function (this: TooltipView) { this._showTooltipContent( tooltipModel, markupText, params, asyncTicket, e.offsetX, e.offsetY, e.position, e.target, markupStyleCreator ); }); // FIXME // duplicated showtip if manuallyShowTip is called from dispatchAction. dispatchAction({ type: 'showTip', dataIndexInside: dataIndex, dataIndex: data.getRawIndex(dataIndex), seriesIndex: seriesIndex, from: this.uid }); } private _showComponentItemTooltip( e: TryShowParams, el: ECElement, dispatchAction: ExtensionAPI['dispatchAction'] ) { const ecData = getECData(el); const tooltipConfig = ecData.tooltipConfig; let tooltipOpt = tooltipConfig.option || {}; if (isString(tooltipOpt)) { const content = tooltipOpt; tooltipOpt = { content: content, // Fixed formatter formatter: content }; } const tooltipModelCascade = [tooltipOpt] as TooltipModelOptionCascade[]; const cmpt = this._ecModel.getComponent(ecData.componentMainType, ecData.componentIndex); if (cmpt) { tooltipModelCascade.push(cmpt as Model); } // In most cases, component tooltip formatter has different params with series tooltip formatter, // so that they cannot share the same formatter. Since the global tooltip formatter is used for series // by convention, we do not use it as the default formatter for component. tooltipModelCascade.push({ formatter: tooltipOpt.content }); const positionDefault = e.positionDefault; const subTooltipModel = buildTooltipModel( tooltipModelCascade, this._tooltipModel, positionDefault ? { position: positionDefault } : null ); const defaultHtml = subTooltipModel.get('content'); const asyncTicket = Math.random() + ''; // PENDING: this case do not support richText style yet. const markupStyleCreator = new TooltipMarkupStyleCreator(); // Do not check whether `trigger` is 'none' here, because `trigger` // only works on coordinate system. In fact, we have not found case // that requires setting `trigger` nothing on component yet. this._showOrMove(subTooltipModel, function (this: TooltipView) { // Use formatterParams from element defined in component // Avoid users modify it. const formatterParams = clone(subTooltipModel.get('formatterParams') as any || {}); this._showTooltipContent( subTooltipModel, defaultHtml, formatterParams, asyncTicket, e.offsetX, e.offsetY, e.position, el, markupStyleCreator ); }); // If not dispatch showTip, tip may be hide triggered by axis. dispatchAction({ type: 'showTip', from: this.uid }); } private _showTooltipContent( // Use Model insteadof TooltipModel because this model may be from series or other options. // Instead of top level tooltip. tooltipModel: Model, defaultHtml: string, params: TooltipCallbackDataParams | TooltipCallbackDataParams[], asyncTicket: string, x: number, y: number, positionExpr: TooltipOption['position'], el: ECElement, markupStyleCreator: TooltipMarkupStyleCreator ) { // Reset ticket this._ticket = ''; if (!tooltipModel.get('showContent') || !tooltipModel.get('show')) { return; } const tooltipContent = this._tooltipContent; tooltipContent.setEnterable(tooltipModel.get('enterable')); const formatter = tooltipModel.get('formatter'); positionExpr = positionExpr || tooltipModel.get('position'); let html: string | HTMLElement | HTMLElement[] = defaultHtml; const nearPoint = this._getNearestPoint( [x, y], params, tooltipModel.get('trigger'), tooltipModel.get('borderColor') ); const nearPointColor = nearPoint.color; if (formatter) { if (isString(formatter)) { const useUTC = tooltipModel.ecModel.get('useUTC'); const params0 = isArray(params) ? params[0] : params; const isTimeAxis = params0 && params0.axisType && params0.axisType.indexOf('time') >= 0; html = formatter; if (isTimeAxis) { html = timeFormat(params0.axisValue, html, useUTC); } html = formatTpl(html, params, true); } else if (isFunction(formatter)) { const callback = bind(function (cbTicket: string, html: string | HTMLElement | HTMLElement[]) { if (cbTicket === this._ticket) { tooltipContent.setContent(html, markupStyleCreator, tooltipModel, nearPointColor, positionExpr); this._updatePosition( tooltipModel, positionExpr, x, y, tooltipContent, params, el ); } }, this); this._ticket = asyncTicket; html = formatter(params, asyncTicket, callback); } else { html = formatter; } } tooltipContent.setContent(html, markupStyleCreator, tooltipModel, nearPointColor, positionExpr); tooltipContent.show(tooltipModel, nearPointColor); this._updatePosition( tooltipModel, positionExpr, x, y, tooltipContent, params, el ); } private _getNearestPoint( point: number[], tooltipDataParams: TooltipCallbackDataParams | TooltipCallbackDataParams[], trigger: TooltipOption['trigger'], borderColor: ZRColor ): { color: ZRColor; } { if (trigger === 'axis' || isArray(tooltipDataParams)) { return { color: borderColor || (this._renderMode === 'html' ? '#fff' : 'none') }; } if (!isArray(tooltipDataParams)) { return { color: borderColor || tooltipDataParams.color || tooltipDataParams.borderColor }; } } _updatePosition( tooltipModel: Model, positionExpr: TooltipOption['position'], x: number, // Mouse x y: number, // Mouse y content: TooltipHTMLContent | TooltipRichContent, params: TooltipCallbackDataParams | TooltipCallbackDataParams[], el?: Element ) { const viewWidth = this._api.getWidth(); const viewHeight = this._api.getHeight(); positionExpr = positionExpr || tooltipModel.get('position'); const contentSize = content.getSize(); let align = tooltipModel.get('align'); let vAlign = tooltipModel.get('verticalAlign'); const rect = el && el.getBoundingRect().clone(); el && rect.applyTransform(el.transform); if (isFunction(positionExpr)) { // Callback of position can be an array or a string specify the position positionExpr = positionExpr([x, y], params, content.el, rect, { viewSize: [viewWidth, viewHeight], contentSize: contentSize.slice() as [number, number] }); } if (isArray(positionExpr)) { x = parsePercent(positionExpr[0], viewWidth); y = parsePercent(positionExpr[1], viewHeight); } else if (isObject(positionExpr)) { const boxLayoutPosition = positionExpr as BoxLayoutOptionMixin; boxLayoutPosition.width = contentSize[0]; boxLayoutPosition.height = contentSize[1]; const layoutRect = getLayoutRect( boxLayoutPosition, { width: viewWidth, height: viewHeight } ); x = layoutRect.x; y = layoutRect.y; align = null; // When positionExpr is left/top/right/bottom, // align and verticalAlign will not work. vAlign = null; } // Specify tooltip position by string 'top' 'bottom' 'left' 'right' around graphic element else if (isString(positionExpr) && el) { const pos = calcTooltipPosition( positionExpr, rect, contentSize, tooltipModel.get('borderWidth') ); x = pos[0]; y = pos[1]; } else { const pos = refixTooltipPosition( x, y, content, viewWidth, viewHeight, align ? null : 20, vAlign ? null : 20 ); x = pos[0]; y = pos[1]; } align && (x -= isCenterAlign(align) ? contentSize[0] / 2 : align === 'right' ? contentSize[0] : 0); vAlign && (y -= isCenterAlign(vAlign) ? contentSize[1] / 2 : vAlign === 'bottom' ? contentSize[1] : 0); if (shouldTooltipConfine(tooltipModel)) { const pos = confineTooltipPosition( x, y, content, viewWidth, viewHeight ); x = pos[0]; y = pos[1]; } content.moveTo(x, y); } // FIXME // Should we remove this but leave this to user? private _updateContentNotChangedOnAxis( dataByCoordSys: DataByCoordSys[], cbParamsList: TooltipCallbackDataParams[] ) { const lastCoordSys = this._lastDataByCoordSys; const lastCbParamsList = this._cbParamsList; let contentNotChanged = !!lastCoordSys && lastCoordSys.length === dataByCoordSys.length; contentNotChanged && each(lastCoordSys, (lastItemCoordSys, indexCoordSys) => { const lastDataByAxis = lastItemCoordSys.dataByAxis || [] as DataByAxis[]; const thisItemCoordSys = dataByCoordSys[indexCoordSys] || {} as DataByCoordSys; const thisDataByAxis = thisItemCoordSys.dataByAxis || [] as DataByAxis[]; contentNotChanged = contentNotChanged && lastDataByAxis.length === thisDataByAxis.length; contentNotChanged && each(lastDataByAxis, (lastItem, indexAxis) => { const thisItem = thisDataByAxis[indexAxis] || {} as DataByAxis; const lastIndices = lastItem.seriesDataIndices || [] as DataIndex[]; const newIndices = thisItem.seriesDataIndices || [] as DataIndex[]; contentNotChanged = contentNotChanged && lastItem.value === thisItem.value && lastItem.axisType === thisItem.axisType && lastItem.axisId === thisItem.axisId && lastIndices.length === newIndices.length; contentNotChanged && each(lastIndices, (lastIdxItem, j) => { const newIdxItem = newIndices[j]; contentNotChanged = contentNotChanged && lastIdxItem.seriesIndex === newIdxItem.seriesIndex && lastIdxItem.dataIndex === newIdxItem.dataIndex; }); // check is cbParams data value changed lastCbParamsList && each(lastItem.seriesDataIndices, (idxItem) => { const seriesIdx = idxItem.seriesIndex; const cbParams = cbParamsList[seriesIdx]; const lastCbParams = lastCbParamsList[seriesIdx]; if (cbParams && lastCbParams && lastCbParams.data !== cbParams.data) { contentNotChanged = false; } }); }); }); this._lastDataByCoordSys = dataByCoordSys; this._cbParamsList = cbParamsList; return !!contentNotChanged; } private _hide(dispatchAction: ExtensionAPI['dispatchAction']) { // Do not directly hideLater here, because this behavior may be prevented // in dispatchAction when showTip is dispatched. // FIXME // duplicated hideTip if manuallyHideTip is called from dispatchAction. this._lastDataByCoordSys = null; dispatchAction({ type: 'hideTip', from: this.uid }); } dispose(ecModel: GlobalModel, api: ExtensionAPI) { if (env.node || !api.getDom()) { return; } clear(this, '_updatePosition'); this._tooltipContent.dispose(); globalListener.unregister('itemTooltip', api); } } type TooltipableOption = { tooltip?: CommonTooltipOption; }; type TooltipModelOptionCascade = Model | CommonTooltipOption | string; /** * From top to bottom. (the last one should be globalTooltipModel); */ function buildTooltipModel( modelCascade: TooltipModelOptionCascade[], globalTooltipModel: TooltipModel, defaultTooltipOption?: CommonTooltipOption ): Model> { // Last is always tooltip model. const ecModel = globalTooltipModel.ecModel; let resultModel: Model>; if (defaultTooltipOption) { resultModel = new Model(defaultTooltipOption, ecModel, ecModel); resultModel = new Model(globalTooltipModel.option, resultModel, ecModel); } else { resultModel = globalTooltipModel as Model>; } for (let i = modelCascade.length - 1; i >= 0; i--) { let tooltipOpt = modelCascade[i]; if (tooltipOpt) { if (tooltipOpt instanceof Model) { tooltipOpt = (tooltipOpt as Model).get('tooltip', true); } // In each data item tooltip can be simply write: // { // value: 10, // tooltip: 'Something you need to know' // } if (isString(tooltipOpt)) { tooltipOpt = { formatter: tooltipOpt }; } if (tooltipOpt) { resultModel = new Model(tooltipOpt, resultModel, ecModel); } } } return resultModel as Model>; } function makeDispatchAction(payload: ShowTipPayload | HideTipPayload, api: ExtensionAPI) { return payload.dispatchAction || bind(api.dispatchAction, api); } function refixTooltipPosition( x: number, y: number, content: TooltipHTMLContent | TooltipRichContent, viewWidth: number, viewHeight: number, gapH: number, gapV: number ) { const size = content.getSize(); const width = size[0]; const height = size[1]; if (gapH != null) { // Add extra 2 pixels for this case: // At present the "values" in default tooltip are using CSS `float: right`. // When the right edge of the tooltip box is on the right side of the // viewport, the `float` layout might push the "values" to the second line. if (x + width + gapH + 2 > viewWidth) { x -= width + gapH; } else { x += gapH; } } if (gapV != null) { if (y + height + gapV > viewHeight) { y -= height + gapV; } else { y += gapV; } } return [x, y]; } function confineTooltipPosition( x: number, y: number, content: TooltipHTMLContent | TooltipRichContent, viewWidth: number, viewHeight: number ): [number, number] { const size = content.getSize(); const width = size[0]; const height = size[1]; 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]; } function calcTooltipPosition( position: TooltipOption['position'], rect: ZRRectLike, contentSize: number[], borderWidth: number ): [number, number] { const domWidth = contentSize[0]; const domHeight = contentSize[1]; const offset = Math.ceil(Math.SQRT2 * borderWidth) + 8; let x = 0; let y = 0; const rectWidth = rect.width; const rectHeight = rect.height; switch (position) { case 'inside': x = rect.x + rectWidth / 2 - domWidth / 2; y = rect.y + rectHeight / 2 - domHeight / 2; break; case 'top': x = rect.x + rectWidth / 2 - domWidth / 2; y = rect.y - domHeight - offset; break; case 'bottom': x = rect.x + rectWidth / 2 - domWidth / 2; y = rect.y + rectHeight + offset; break; case 'left': x = rect.x - domWidth - offset; y = rect.y + rectHeight / 2 - domHeight / 2; break; case 'right': x = rect.x + rectWidth + offset; y = rect.y + rectHeight / 2 - domHeight / 2; } return [x, y]; } function isCenterAlign(align: HorizontalAlign | VerticalAlign) { return align === 'center' || align === 'middle'; } /** * Find target component by payload like: * ```js * { legendId: 'some_id', name: 'xxx' } * { toolboxIndex: 1, name: 'xxx' } * { geoName: 'some_name', name: 'xxx' } * ``` * PENDING: at present only * * If not found, return null/undefined. */ function findComponentReference( payload: ShowTipPayload, ecModel: GlobalModel, api: ExtensionAPI ): { componentMainType: ComponentMainType; componentIndex: number; el: ECElement; } { const { queryOptionMap } = preParseFinder(payload); const componentMainType = queryOptionMap.keys()[0]; if (!componentMainType || componentMainType === 'series') { return; } const queryResult = queryReferringComponents( ecModel, componentMainType, queryOptionMap.get(componentMainType), { useDefault: false, enableAll: false, enableNone: false } ); const model = queryResult.models[0]; if (!model) { return; } const view = api.getViewOfComponentModel(model); let el: ECElement; view.group.traverse((subEl: ECElement) => { const tooltipConfig = getECData(subEl).tooltipConfig; if (tooltipConfig && tooltipConfig.name === payload.name) { el = subEl; return true; // stop } }); if (el) { return { componentMainType, componentIndex: model.componentIndex, el }; } } export default TooltipView;