| /* |
| * 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 {makeInner, ModelFinderObject} from '../../util/model'; |
| import * as modelHelper from './modelHelper'; |
| import findPointFromSeries from './findPointFromSeries'; |
| import GlobalModel from '../../model/Global'; |
| import ExtensionAPI from '../../core/ExtensionAPI'; |
| import { Dictionary, Payload, CommonAxisPointerOption, HighlightPayload, DownplayPayload } from '../../util/types'; |
| import AxisPointerModel, { AxisPointerOption } from './AxisPointerModel'; |
| import { each, curry, bind, extend, Curry1 } from 'zrender/src/core/util'; |
| import { ZRenderType } from 'zrender/src/zrender'; |
| |
| const inner = makeInner<{ |
| axisPointerLastHighlights: Dictionary<BatchItem> |
| }, ZRenderType>(); |
| |
| type AxisValue = CommonAxisPointerOption['value']; |
| |
| interface DataIndex { |
| seriesIndex: number |
| dataIndex: number |
| dataIndexInside: number |
| } |
| |
| type BatchItem = DataIndex; |
| |
| export interface DataByAxis { |
| // TODO: TYPE Value type |
| value: string | number |
| axisIndex: number |
| axisDim: string |
| axisType: string |
| axisId: string |
| |
| seriesDataIndices: DataIndex[] |
| |
| valueLabelOpt: { |
| precision: AxisPointerOption['label']['precision'] |
| formatter: AxisPointerOption['label']['formatter'] |
| } |
| } |
| export interface DataByCoordSys { |
| coordSysId: string |
| coordSysIndex: number |
| coordSysType: string |
| coordSysMainType: string |
| dataByAxis: DataByAxis[] |
| } |
| interface DataByCoordSysCollection { |
| list: DataByCoordSys[] |
| map: Dictionary<DataByCoordSys> |
| } |
| |
| type CollectedCoordInfo = ReturnType<typeof modelHelper['collect']>; |
| type CollectedAxisInfo = CollectedCoordInfo['axesInfo'][string]; |
| |
| interface AxisTriggerPayload extends Payload { |
| currTrigger?: 'click' | 'mousemove' | 'leave' |
| /** |
| * x and y, which are mandatory, specify a point to trigger axisPointer and tooltip. |
| */ |
| x?: number |
| /** |
| * x and y, which are mandatory, specify a point to trigger axisPointer and tooltip. |
| */ |
| y?: number |
| /** |
| * finder, optional, restrict target axes. |
| */ |
| seriesIndex?: number |
| dataIndex: number |
| |
| axesInfo?: { |
| // 'x'|'y'|'angle' |
| axisDim?: string |
| axisIndex?: number |
| value?: AxisValue |
| }[] |
| |
| dispatchAction: ExtensionAPI['dispatchAction'] |
| } |
| |
| type ShowValueMap = Dictionary<{ |
| value: AxisValue |
| payloadBatch: BatchItem[] |
| }>; |
| |
| /** |
| * Basic logic: check all axis, if they do not demand show/highlight, |
| * then hide/downplay them. |
| * |
| * @return content of event obj for echarts.connect. |
| */ |
| export default function axisTrigger( |
| payload: AxisTriggerPayload, |
| ecModel: GlobalModel, |
| api: ExtensionAPI |
| ) { |
| const currTrigger = payload.currTrigger; |
| let point = [payload.x, payload.y]; |
| const finder = payload; |
| const dispatchAction = payload.dispatchAction || bind(api.dispatchAction, api); |
| const coordSysAxesInfo = (ecModel.getComponent('axisPointer') as AxisPointerModel) |
| .coordSysAxesInfo as CollectedCoordInfo; |
| |
| // Pending |
| // See #6121. But we are not able to reproduce it yet. |
| if (!coordSysAxesInfo) { |
| return; |
| } |
| |
| if (illegalPoint(point)) { |
| // Used in the default behavior of `connection`: use the sample seriesIndex |
| // and dataIndex. And also used in the tooltipView trigger. |
| point = findPointFromSeries({ |
| seriesIndex: finder.seriesIndex, |
| // Do not use dataIndexInside from other ec instance. |
| // FIXME: auto detect it? |
| dataIndex: finder.dataIndex |
| }, ecModel).point; |
| } |
| const isIllegalPoint = illegalPoint(point); |
| |
| // Axis and value can be specified when calling dispatchAction({type: 'updateAxisPointer'}). |
| // Notice: In this case, it is difficult to get the `point` (which is necessary to show |
| // tooltip, so if point is not given, we just use the point found by sample seriesIndex |
| // and dataIndex. |
| const inputAxesInfo = finder.axesInfo; |
| |
| const axesInfo = coordSysAxesInfo.axesInfo; |
| const shouldHide = currTrigger === 'leave' || illegalPoint(point); |
| const outputPayload = {} as AxisTriggerPayload; |
| |
| const showValueMap: ShowValueMap = {}; |
| const dataByCoordSys: DataByCoordSysCollection = { |
| list: [], |
| map: {} |
| }; |
| const updaters = { |
| showPointer: curry(showPointer, showValueMap), |
| showTooltip: curry(showTooltip, dataByCoordSys) |
| }; |
| |
| // Process for triggered axes. |
| each(coordSysAxesInfo.coordSysMap, function (coordSys, coordSysKey) { |
| // If a point given, it must be contained by the coordinate system. |
| const coordSysContainsPoint = isIllegalPoint || coordSys.containPoint(point); |
| |
| each(coordSysAxesInfo.coordSysAxesInfo[coordSysKey], function (axisInfo, key) { |
| const axis = axisInfo.axis; |
| const inputAxisInfo = findInputAxisInfo(inputAxesInfo, axisInfo); |
| // If no inputAxesInfo, no axis is restricted. |
| if (!shouldHide && coordSysContainsPoint && (!inputAxesInfo || inputAxisInfo)) { |
| let val = inputAxisInfo && inputAxisInfo.value; |
| if (val == null && !isIllegalPoint) { |
| val = axis.pointToData(point); |
| } |
| val != null && processOnAxis(axisInfo, val, updaters, false, outputPayload); |
| } |
| }); |
| }); |
| |
| // Process for linked axes. |
| const linkTriggers: Dictionary<AxisValue> = {}; |
| each(axesInfo, function (tarAxisInfo, tarKey) { |
| const linkGroup = tarAxisInfo.linkGroup; |
| |
| // If axis has been triggered in the previous stage, it should not be triggered by link. |
| if (linkGroup && !showValueMap[tarKey]) { |
| each(linkGroup.axesInfo, function (srcAxisInfo, srcKey) { |
| const srcValItem = showValueMap[srcKey]; |
| // If srcValItem exist, source axis is triggered, so link to target axis. |
| if (srcAxisInfo !== tarAxisInfo && srcValItem) { |
| let val = srcValItem.value; |
| linkGroup.mapper && (val = tarAxisInfo.axis.scale.parse(linkGroup.mapper( |
| val, makeMapperParam(srcAxisInfo), makeMapperParam(tarAxisInfo) |
| ))); |
| linkTriggers[tarAxisInfo.key] = val; |
| } |
| }); |
| } |
| }); |
| each(linkTriggers, function (val, tarKey) { |
| processOnAxis(axesInfo[tarKey], val, updaters, true, outputPayload); |
| }); |
| |
| updateModelActually(showValueMap, axesInfo, outputPayload); |
| dispatchTooltipActually(dataByCoordSys, point, payload, dispatchAction); |
| dispatchHighDownActually(axesInfo, dispatchAction, api); |
| |
| return outputPayload; |
| } |
| |
| function processOnAxis( |
| axisInfo: CollectedCoordInfo['axesInfo'][string], |
| newValue: AxisValue, |
| updaters: { |
| showPointer: Curry1<typeof showPointer, ShowValueMap> |
| showTooltip: Curry1<typeof showTooltip, DataByCoordSysCollection> |
| }, |
| noSnap: boolean, |
| outputFinder: ModelFinderObject |
| ) { |
| const axis = axisInfo.axis; |
| |
| if (axis.scale.isBlank() || !axis.containData(newValue)) { |
| return; |
| } |
| |
| if (!axisInfo.involveSeries) { |
| updaters.showPointer(axisInfo, newValue); |
| return; |
| } |
| |
| // Heavy calculation. So put it after axis.containData checking. |
| const payloadInfo = buildPayloadsBySeries(newValue, axisInfo); |
| const payloadBatch = payloadInfo.payloadBatch; |
| const snapToValue = payloadInfo.snapToValue; |
| |
| // Fill content of event obj for echarts.connect. |
| // By default use the first involved series data as a sample to connect. |
| if (payloadBatch[0] && outputFinder.seriesIndex == null) { |
| extend(outputFinder, payloadBatch[0]); |
| } |
| |
| // If no linkSource input, this process is for collecting link |
| // target, where snap should not be accepted. |
| if (!noSnap && axisInfo.snap) { |
| if (axis.containData(snapToValue) && snapToValue != null) { |
| newValue = snapToValue; |
| } |
| } |
| |
| updaters.showPointer(axisInfo, newValue, payloadBatch); |
| // Tooltip should always be snapToValue, otherwise there will be |
| // incorrect "axis value ~ series value" mapping displayed in tooltip. |
| updaters.showTooltip(axisInfo, payloadInfo, snapToValue); |
| } |
| |
| function buildPayloadsBySeries(value: AxisValue, axisInfo: CollectedAxisInfo) { |
| const axis = axisInfo.axis; |
| const dim = axis.dim; |
| let snapToValue = value; |
| const payloadBatch: BatchItem[] = []; |
| let minDist = Number.MAX_VALUE; |
| let minDiff = -1; |
| |
| each(axisInfo.seriesModels, function (series, idx) { |
| const dataDim = series.getData().mapDimensionsAll(dim); |
| let seriesNestestValue; |
| let dataIndices; |
| |
| if (series.getAxisTooltipData) { |
| const result = series.getAxisTooltipData(dataDim, value, axis); |
| dataIndices = result.dataIndices; |
| seriesNestestValue = result.nestestValue; |
| } |
| else { |
| dataIndices = series.getData().indicesOfNearest( |
| dataDim[0], |
| value as number, |
| // Add a threshold to avoid find the wrong dataIndex |
| // when data length is not same. |
| // false, |
| axis.type === 'category' ? 0.5 : null |
| ); |
| if (!dataIndices.length) { |
| return; |
| } |
| seriesNestestValue = series.getData().get(dataDim[0], dataIndices[0]); |
| } |
| |
| if (seriesNestestValue == null || !isFinite(seriesNestestValue)) { |
| return; |
| } |
| |
| const diff = value as number - seriesNestestValue; |
| const dist = Math.abs(diff); |
| // Consider category case |
| if (dist <= minDist) { |
| if (dist < minDist || (diff >= 0 && minDiff < 0)) { |
| minDist = dist; |
| minDiff = diff; |
| snapToValue = seriesNestestValue; |
| payloadBatch.length = 0; |
| } |
| each(dataIndices, function (dataIndex) { |
| payloadBatch.push({ |
| seriesIndex: series.seriesIndex, |
| dataIndexInside: dataIndex, |
| dataIndex: series.getData().getRawIndex(dataIndex) |
| }); |
| }); |
| } |
| }); |
| |
| return { |
| payloadBatch: payloadBatch, |
| snapToValue: snapToValue |
| }; |
| } |
| |
| function showPointer( |
| showValueMap: ShowValueMap, |
| axisInfo: CollectedAxisInfo, |
| value: AxisValue, |
| payloadBatch?: BatchItem[] |
| ) { |
| showValueMap[axisInfo.key] = { |
| value: value, |
| payloadBatch: payloadBatch |
| }; |
| } |
| |
| function showTooltip( |
| dataByCoordSys: DataByCoordSysCollection, |
| axisInfo: CollectedCoordInfo['axesInfo'][string], |
| payloadInfo: { payloadBatch: BatchItem[] }, |
| value: AxisValue |
| ) { |
| const payloadBatch = payloadInfo.payloadBatch; |
| const axis = axisInfo.axis; |
| const axisModel = axis.model; |
| const axisPointerModel = axisInfo.axisPointerModel; |
| |
| // If no data, do not create anything in dataByCoordSys, |
| // whose length will be used to judge whether dispatch action. |
| if (!axisInfo.triggerTooltip || !payloadBatch.length) { |
| return; |
| } |
| |
| const coordSysModel = axisInfo.coordSys.model; |
| const coordSysKey = modelHelper.makeKey(coordSysModel); |
| let coordSysItem = dataByCoordSys.map[coordSysKey]; |
| if (!coordSysItem) { |
| coordSysItem = dataByCoordSys.map[coordSysKey] = { |
| coordSysId: coordSysModel.id, |
| coordSysIndex: coordSysModel.componentIndex, |
| coordSysType: coordSysModel.type, |
| coordSysMainType: coordSysModel.mainType, |
| dataByAxis: [] |
| }; |
| dataByCoordSys.list.push(coordSysItem); |
| } |
| |
| coordSysItem.dataByAxis.push({ |
| axisDim: axis.dim, |
| axisIndex: axisModel.componentIndex, |
| axisType: axisModel.type, |
| axisId: axisModel.id, |
| value: value as number, |
| // Caustion: viewHelper.getValueLabel is actually on "view stage", which |
| // depends that all models have been updated. So it should not be performed |
| // here. Considering axisPointerModel used here is volatile, which is hard |
| // to be retrieve in TooltipView, we prepare parameters here. |
| valueLabelOpt: { |
| precision: axisPointerModel.get(['label', 'precision']), |
| formatter: axisPointerModel.get(['label', 'formatter']) |
| }, |
| seriesDataIndices: payloadBatch.slice() |
| }); |
| } |
| |
| function updateModelActually( |
| showValueMap: ShowValueMap, |
| axesInfo: Dictionary<CollectedAxisInfo>, |
| outputPayload: AxisTriggerPayload |
| ) { |
| const outputAxesInfo: AxisTriggerPayload['axesInfo'] = outputPayload.axesInfo = []; |
| // Basic logic: If no 'show' required, 'hide' this axisPointer. |
| each(axesInfo, function (axisInfo, key) { |
| const option = axisInfo.axisPointerModel.option; |
| const valItem = showValueMap[key]; |
| |
| if (valItem) { |
| !axisInfo.useHandle && (option.status = 'show'); |
| option.value = valItem.value; |
| // For label formatter param and highlight. |
| option.seriesDataIndices = (valItem.payloadBatch || []).slice(); |
| } |
| // When always show (e.g., handle used), remain |
| // original value and status. |
| else { |
| // If hide, value still need to be set, consider |
| // click legend to toggle axis blank. |
| !axisInfo.useHandle && (option.status = 'hide'); |
| } |
| |
| // If status is 'hide', should be no info in payload. |
| option.status === 'show' && outputAxesInfo.push({ |
| axisDim: axisInfo.axis.dim, |
| axisIndex: axisInfo.axis.model.componentIndex, |
| value: option.value |
| }); |
| }); |
| } |
| |
| function dispatchTooltipActually( |
| dataByCoordSys: DataByCoordSysCollection, |
| point: number[], |
| payload: AxisTriggerPayload, |
| dispatchAction: ExtensionAPI['dispatchAction'] |
| ) { |
| // Basic logic: If no showTip required, hideTip will be dispatched. |
| if (illegalPoint(point) || !dataByCoordSys.list.length) { |
| dispatchAction({type: 'hideTip'}); |
| return; |
| } |
| |
| // In most case only one axis (or event one series is used). It is |
| // convinient to fetch payload.seriesIndex and payload.dataIndex |
| // dirtectly. So put the first seriesIndex and dataIndex of the first |
| // axis on the payload. |
| const sampleItem = ((dataByCoordSys.list[0].dataByAxis[0] || {}).seriesDataIndices || [])[0] || {} as DataIndex; |
| |
| dispatchAction({ |
| type: 'showTip', |
| escapeConnect: true, |
| x: point[0], |
| y: point[1], |
| tooltipOption: payload.tooltipOption, |
| position: payload.position, |
| dataIndexInside: sampleItem.dataIndexInside, |
| dataIndex: sampleItem.dataIndex, |
| seriesIndex: sampleItem.seriesIndex, |
| dataByCoordSys: dataByCoordSys.list |
| }); |
| } |
| |
| function dispatchHighDownActually( |
| axesInfo: Dictionary<CollectedAxisInfo>, |
| dispatchAction: ExtensionAPI['dispatchAction'], |
| api: ExtensionAPI |
| ) { |
| // FIXME |
| // highlight status modification shoule be a stage of main process? |
| // (Consider confilct (e.g., legend and axisPointer) and setOption) |
| |
| const zr = api.getZr(); |
| const highDownKey = 'axisPointerLastHighlights' as const; |
| const lastHighlights = inner(zr)[highDownKey] || {}; |
| const newHighlights: Dictionary<BatchItem> = inner(zr)[highDownKey] = {}; |
| |
| // Update highlight/downplay status according to axisPointer model. |
| // Build hash map and remove duplicate incidentally. |
| each(axesInfo, function (axisInfo, key) { |
| const option = axisInfo.axisPointerModel.option; |
| option.status === 'show' && each(option.seriesDataIndices, function (batchItem) { |
| const key = batchItem.seriesIndex + ' | ' + batchItem.dataIndex; |
| newHighlights[key] = batchItem; |
| }); |
| }); |
| |
| // Diff. |
| const toHighlight: BatchItem[] = []; |
| const toDownplay: BatchItem[] = []; |
| each(lastHighlights, function (batchItem, key) { |
| !newHighlights[key] && toDownplay.push(batchItem); |
| }); |
| each(newHighlights, function (batchItem, key) { |
| !lastHighlights[key] && toHighlight.push(batchItem); |
| }); |
| |
| toDownplay.length && api.dispatchAction({ |
| type: 'downplay', |
| escapeConnect: true, |
| // Not blur others when highlight in axisPointer. |
| notBlur: true, |
| batch: toDownplay |
| } as DownplayPayload); |
| toHighlight.length && api.dispatchAction({ |
| type: 'highlight', |
| escapeConnect: true, |
| // Not blur others when highlight in axisPointer. |
| notBlur: true, |
| batch: toHighlight |
| } as HighlightPayload); |
| } |
| |
| function findInputAxisInfo( |
| inputAxesInfo: AxisTriggerPayload['axesInfo'], |
| axisInfo: CollectedAxisInfo |
| ) { |
| for (let i = 0; i < (inputAxesInfo || []).length; i++) { |
| const inputAxisInfo = inputAxesInfo[i]; |
| if (axisInfo.axis.dim === inputAxisInfo.axisDim |
| && axisInfo.axis.model.componentIndex === inputAxisInfo.axisIndex |
| ) { |
| return inputAxisInfo; |
| } |
| } |
| } |
| |
| function makeMapperParam(axisInfo: CollectedAxisInfo) { |
| const axisModel = axisInfo.axis.model; |
| const item = {} as { |
| axisDim: string |
| axisIndex: number |
| axisId: string |
| axisName: string |
| // TODO `dim`AxisIndex, `dim`AxisName, `dim`AxisId? |
| }; |
| const dim = item.axisDim = axisInfo.axis.dim; |
| item.axisIndex = (item as any)[dim + 'AxisIndex'] = axisModel.componentIndex; |
| item.axisName = (item as any)[dim + 'AxisName'] = axisModel.name; |
| item.axisId = (item as any)[dim + 'AxisId'] = axisModel.id; |
| return item; |
| } |
| |
| function illegalPoint(point?: number[]) { |
| return !point || point[0] == null || isNaN(point[0]) || point[1] == null || isNaN(point[1]); |
| } |