/* * 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 * as zrUtil from 'zrender/src/core/util'; import * as graphic from '../../util/graphic'; import * as axisPointerModelHelper from './modelHelper'; import * as eventTool from 'zrender/src/core/event'; import * as throttleUtil from '../../util/throttle'; import {makeInner} from '../../util/model'; import { AxisPointer } from './AxisPointer'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; import ExtensionAPI from '../../core/ExtensionAPI'; import Displayable, { DisplayableProps } from 'zrender/src/graphic/Displayable'; import Element from 'zrender/src/Element'; import { VerticalAlign, HorizontalAlign, CommonAxisPointerOption } from '../../util/types'; import { PathProps } from 'zrender/src/graphic/Path'; import Model from '../../model/Model'; import { TextProps } from 'zrender/src/graphic/Text'; const inner = makeInner<{ lastProp?: DisplayableProps labelEl?: graphic.Text pointerEl?: Displayable }, Element>(); const clone = zrUtil.clone; const bind = zrUtil.bind; type Icon = ReturnType; interface Transform { x: number, y: number, rotation: number } type AxisValue = CommonAxisPointerOption['value']; // Not use top level axisPointer model type AxisPointerModel = Model; interface BaseAxisPointer { /** * Should be implemenented by sub-class if support `handle`. */ getHandleTransform(value: AxisValue, axisModel: AxisBaseModel, axisPointerModel: AxisPointerModel): Transform /** * * Should be implemenented by sub-class if support `handle`. */ updateHandleTransform( transform: Transform, delta: number[], axisModel: AxisBaseModel, axisPointerModel: AxisPointerModel ): Transform & { cursorPoint: number[] tooltipOption?: { verticalAlign?: VerticalAlign align?: HorizontalAlign } } } export interface AxisPointerElementOptions { graphicKey: string pointer: PathProps & { type: 'Line' | 'Rect' | 'Circle' | 'Sector' } label: TextProps } /** * Base axis pointer class in 2D. */ class BaseAxisPointer implements AxisPointer { private _group: graphic.Group; private _lastGraphicKey: string; private _handle: Icon; private _dragging = false; private _lastValue: AxisValue; private _lastStatus: CommonAxisPointerOption['status']; private _payloadInfo: ReturnType; /** * If have transition animation */ private _moveAnimation: boolean; private _axisModel: AxisBaseModel; private _axisPointerModel: AxisPointerModel; private _api: ExtensionAPI; /** * In px, arbitrary value. Do not set too small, * no animation is ok for most cases. */ protected animationThreshold = 15; /** * @implement */ render(axisModel: AxisBaseModel, axisPointerModel: AxisPointerModel, api: ExtensionAPI, forceRender?: boolean) { const value = axisPointerModel.get('value'); const status = axisPointerModel.get('status'); // Bind them to `this`, not in closure, otherwise they will not // be replaced when user calling setOption in not merge mode. this._axisModel = axisModel; this._axisPointerModel = axisPointerModel; this._api = api; // Optimize: `render` will be called repeatedly during mouse move. // So it is power consuming if performing `render` each time, // especially on mobile device. if (!forceRender && this._lastValue === value && this._lastStatus === status ) { return; } this._lastValue = value; this._lastStatus = status; let group = this._group; const handle = this._handle; if (!status || status === 'hide') { // Do not clear here, for animation better. group && group.hide(); handle && handle.hide(); return; } group && group.show(); handle && handle.show(); // Otherwise status is 'show' const elOption = {} as AxisPointerElementOptions; this.makeElOption(elOption, value, axisModel, axisPointerModel, api); // Enable change axis pointer type. const graphicKey = elOption.graphicKey; if (graphicKey !== this._lastGraphicKey) { this.clear(api); } this._lastGraphicKey = graphicKey; const moveAnimation = this._moveAnimation = this.determineAnimation(axisModel, axisPointerModel); if (!group) { group = this._group = new graphic.Group(); this.createPointerEl(group, elOption, axisModel, axisPointerModel); this.createLabelEl(group, elOption, axisModel, axisPointerModel); api.getZr().add(group); } else { const doUpdateProps = zrUtil.curry(updateProps, axisPointerModel, moveAnimation); this.updatePointerEl(group, elOption, doUpdateProps); this.updateLabelEl(group, elOption, doUpdateProps, axisPointerModel); } updateMandatoryProps(group, axisPointerModel, true); this._renderHandle(value); } /** * @implement */ remove(api: ExtensionAPI) { this.clear(api); } /** * @implement */ dispose(api: ExtensionAPI) { this.clear(api); } /** * @protected */ determineAnimation(axisModel: AxisBaseModel, axisPointerModel: AxisPointerModel): boolean { const animation = axisPointerModel.get('animation'); const axis = axisModel.axis; const isCategoryAxis = axis.type === 'category'; const useSnap = axisPointerModel.get('snap'); // Value axis without snap always do not snap. if (!useSnap && !isCategoryAxis) { return false; } if (animation === 'auto' || animation == null) { const animationThreshold = this.animationThreshold; if (isCategoryAxis && axis.getBandWidth() > animationThreshold) { return true; } // It is important to auto animation when snap used. Consider if there is // a dataZoom, animation will be disabled when too many points exist, while // it will be enabled for better visual effect when little points exist. if (useSnap) { const seriesDataCount = axisPointerModelHelper.getAxisInfo(axisModel).seriesDataCount; const axisExtent = axis.getExtent(); // Approximate band width return Math.abs(axisExtent[0] - axisExtent[1]) / seriesDataCount > animationThreshold; } return false; } return animation === true; } /** * add {pointer, label, graphicKey} to elOption * @protected */ makeElOption( elOption: AxisPointerElementOptions, value: AxisValue, axisModel: AxisBaseModel, axisPointerModel: AxisPointerModel, api: ExtensionAPI ) { // Should be implemenented by sub-class. } /** * @protected */ createPointerEl( group: graphic.Group, elOption: AxisPointerElementOptions, axisModel: AxisBaseModel, axisPointerModel: AxisPointerModel ) { const pointerOption = elOption.pointer; if (pointerOption) { const pointerEl = inner(group).pointerEl = new graphic[pointerOption.type]( clone(elOption.pointer) ); group.add(pointerEl); } } /** * @protected */ createLabelEl( group: graphic.Group, elOption: AxisPointerElementOptions, axisModel: AxisBaseModel, axisPointerModel: AxisPointerModel ) { if (elOption.label) { const labelEl = inner(group).labelEl = new graphic.Text( clone(elOption.label) ); group.add(labelEl); updateLabelShowHide(labelEl, axisPointerModel); } } /** * @protected */ updatePointerEl( group: graphic.Group, elOption: AxisPointerElementOptions, updateProps: (el: Element, props: PathProps) => void ) { const pointerEl = inner(group).pointerEl; if (pointerEl && elOption.pointer) { pointerEl.setStyle(elOption.pointer.style); updateProps(pointerEl, {shape: elOption.pointer.shape}); } } /** * @protected */ updateLabelEl( group: graphic.Group, elOption: AxisPointerElementOptions, updateProps: (el: Element, props: PathProps) => void, axisPointerModel: AxisPointerModel ) { const labelEl = inner(group).labelEl; if (labelEl) { labelEl.setStyle(elOption.label.style); updateProps(labelEl, { // Consider text length change in vertical axis, animation should // be used on shape, otherwise the effect will be weird. // TODOTODO // shape: elOption.label.shape, x: elOption.label.x, y: elOption.label.y }); updateLabelShowHide(labelEl, axisPointerModel); } } /** * @private */ _renderHandle(value: AxisValue) { if (this._dragging || !this.updateHandleTransform) { return; } const axisPointerModel = this._axisPointerModel; const zr = this._api.getZr(); let handle = this._handle; const handleModel = axisPointerModel.getModel('handle'); const status = axisPointerModel.get('status'); if (!handleModel.get('show') || !status || status === 'hide') { handle && zr.remove(handle); this._handle = null; return; } let isInit; if (!this._handle) { isInit = true; handle = this._handle = graphic.createIcon( handleModel.get('icon'), { cursor: 'move', draggable: true, onmousemove(e) { // For mobile device, prevent screen slider on the button. eventTool.stop(e.event); }, onmousedown: bind(this._onHandleDragMove, this, 0, 0), drift: bind(this._onHandleDragMove, this), ondragend: bind(this._onHandleDragEnd, this) } ); zr.add(handle); } updateMandatoryProps(handle, axisPointerModel, false); // update style (handle as graphic.Path).setStyle(handleModel.getItemStyle(null, [ 'color', 'borderColor', 'borderWidth', 'opacity', 'shadowColor', 'shadowBlur', 'shadowOffsetX', 'shadowOffsetY' ])); // update position let handleSize = handleModel.get('size'); if (!zrUtil.isArray(handleSize)) { handleSize = [handleSize, handleSize]; } handle.scaleX = handleSize[0] / 2; handle.scaleY = handleSize[1] / 2; throttleUtil.createOrUpdate( this, '_doDispatchAxisPointer', handleModel.get('throttle') || 0, 'fixRate' ); this._moveHandleToValue(value, isInit); } private _moveHandleToValue(value: AxisValue, isInit?: boolean) { updateProps( this._axisPointerModel, !isInit && this._moveAnimation, this._handle, getHandleTransProps(this.getHandleTransform( value, this._axisModel, this._axisPointerModel )) ); } private _onHandleDragMove(dx: number, dy: number) { const handle = this._handle; if (!handle) { return; } this._dragging = true; // Persistent for throttle. const trans = this.updateHandleTransform( getHandleTransProps(handle), [dx, dy], this._axisModel, this._axisPointerModel ); this._payloadInfo = trans; handle.stopAnimation(); (handle as graphic.Path).attr(getHandleTransProps(trans)); inner(handle).lastProp = null; this._doDispatchAxisPointer(); } /** * Throttled method. */ _doDispatchAxisPointer() { const handle = this._handle; if (!handle) { return; } const payloadInfo = this._payloadInfo; const axisModel = this._axisModel; this._api.dispatchAction({ type: 'updateAxisPointer', x: payloadInfo.cursorPoint[0], y: payloadInfo.cursorPoint[1], tooltipOption: payloadInfo.tooltipOption, axesInfo: [{ axisDim: axisModel.axis.dim, axisIndex: axisModel.componentIndex }] }); } private _onHandleDragEnd() { this._dragging = false; const handle = this._handle; if (!handle) { return; } const value = this._axisPointerModel.get('value'); // Consider snap or categroy axis, handle may be not consistent with // axisPointer. So move handle to align the exact value position when // drag ended. this._moveHandleToValue(value); // For the effect: tooltip will be shown when finger holding on handle // button, and will be hidden after finger left handle button. this._api.dispatchAction({ type: 'hideTip' }); } /** * @private */ clear(api: ExtensionAPI) { this._lastValue = null; this._lastStatus = null; const zr = api.getZr(); const group = this._group; const handle = this._handle; if (zr && group) { this._lastGraphicKey = null; group && zr.remove(group); handle && zr.remove(handle); this._group = null; this._handle = null; this._payloadInfo = null; } throttleUtil.clear(this, '_doDispatchAxisPointer'); } /** * @protected */ doClear() { // Implemented by sub-class if necessary. } buildLabel(xy: number[], wh: number[], xDimIndex: 0 | 1) { xDimIndex = xDimIndex || 0; return { x: xy[xDimIndex], y: xy[1 - xDimIndex], width: wh[xDimIndex], height: wh[1 - xDimIndex] }; } } function updateProps( animationModel: AxisPointerModel, moveAnimation: boolean, el: Element, props: DisplayableProps ) { // Animation optimize. if (!propsEqual(inner(el).lastProp, props)) { inner(el).lastProp = props; moveAnimation ? graphic.updateProps(el, props, animationModel as Model< // Ignore animation property Pick >) : (el.stopAnimation(), el.attr(props)); } } function propsEqual(lastProps: any, newProps: any) { if (zrUtil.isObject(lastProps) && zrUtil.isObject(newProps)) { let equals = true; zrUtil.each(newProps, function (item, key) { equals = equals && propsEqual(lastProps[key], item); }); return !!equals; } else { return lastProps === newProps; } } function updateLabelShowHide(labelEl: Element, axisPointerModel: AxisPointerModel) { labelEl[axisPointerModel.get(['label', 'show']) ? 'show' : 'hide'](); } function getHandleTransProps(trans: Transform): Transform { return { x: trans.x || 0, y: trans.y || 0, rotation: trans.rotation || 0 }; } function updateMandatoryProps( group: Element, axisPointerModel: AxisPointerModel, silent?: boolean ) { const z = axisPointerModel.get('z'); const zlevel = axisPointerModel.get('zlevel'); group && group.traverse(function (el: Displayable) { if (el.type !== 'group') { z != null && (el.z = z); zlevel != null && (el.zlevel = zlevel); el.silent = silent; } }); } export default BaseAxisPointer;