Spaces:
Running
Running
| /* | |
| * 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<TooltipCallbackDataParams | TooltipCallbackDataParams[]> | |
| // 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<TooltipCallbackDataParams | TooltipCallbackDataParams[]> | |
| 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, { | |
| appendTo: tooltipModel.get('appendToBody', true) ? 'body' : tooltipModel.get('appendTo', 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<TooltipableOption>(dataIndex), | |
| seriesModel as Model<TooltipableOption>, | |
| (seriesModel.coordinateSystem || {}).model as Model<TooltipableOption> | |
| ], 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) { | |
| const ecData = getECData(el); | |
| if (ecData.ssrType === 'legend') { | |
| // Don't trigger tooltip for legend tooltip item | |
| return; | |
| } | |
| 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<TooltipOption>, | |
| 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<TooltipableOption>], | |
| 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' : '<br/>'; | |
| 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<TooltipableOption>(dataIndex), | |
| dataModel, | |
| seriesModel && (seriesModel.coordinateSystem || {}).model as Model<TooltipableOption> | |
| ], | |
| 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<TooltipableOption>); | |
| } | |
| // 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<TooltipOption> insteadof TooltipModel because this model may be from series or other options. | |
| // Instead of top level tooltip. | |
| tooltipModel: Model<TooltipOption>, | |
| 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<TooltipOption>, | |
| 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<unknown>; | |
| }; | |
| type TooltipModelOptionCascade = | |
| Model<TooltipableOption> | CommonTooltipOption<unknown> | string; | |
| /** | |
| * From top to bottom. (the last one should be globalTooltipModel); | |
| */ | |
| function buildTooltipModel( | |
| modelCascade: TooltipModelOptionCascade[], | |
| globalTooltipModel: TooltipModel, | |
| defaultTooltipOption?: CommonTooltipOption<unknown> | |
| ): Model<TooltipOption & ComponentItemTooltipOption<unknown>> { | |
| // Last is always tooltip model. | |
| const ecModel = globalTooltipModel.ecModel; | |
| let resultModel: Model<TooltipOption & ComponentItemTooltipOption<unknown>>; | |
| if (defaultTooltipOption) { | |
| resultModel = new Model(defaultTooltipOption, ecModel, ecModel); | |
| resultModel = new Model(globalTooltipModel.option, resultModel, ecModel); | |
| } | |
| else { | |
| resultModel = globalTooltipModel as Model<TooltipOption & ComponentItemTooltipOption<unknown>>; | |
| } | |
| for (let i = modelCascade.length - 1; i >= 0; i--) { | |
| let tooltipOpt = modelCascade[i]; | |
| if (tooltipOpt) { | |
| if (tooltipOpt instanceof Model) { | |
| tooltipOpt = (tooltipOpt as Model<TooltipableOption>).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<TooltipOption & ComponentItemTooltipOption<unknown>>; | |
| } | |
| 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; | |