blob: 4c057412e5456b9d37d6d3ed4abbe1bb43ee2112 [file]
/*
* 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 Eventful from 'zrender/src/core/Eventful';
import * as eventTool from 'zrender/src/core/event';
import * as interactionMutex from './interactionMutex';
import { ZRenderType } from 'zrender/src/zrender';
import { ZRElementEvent, RoamOptionMixin, NullUndefined } from '../../util/types';
import { Bind3, isString, bind, defaults, extend, retrieve2 } from 'zrender/src/core/util';
import { makeInner } from '../../util/model';
import { retrieveZInfo } from '../../util/graphic';
import type Component from '../../model/Component';
import ExtensionAPI from '../../core/ExtensionAPI';
import { onIrrelevantElement } from './cursorHelper';
import Displayable from 'zrender/src/graphic/Displayable';
export interface RoamOption {
zInfo: {
// mandatory to provide z and zlevel and pointer checker criteria.
component: Component
z2?: number
}
triggerInfo: {
roamTrigger: RoamOptionMixin['roamTrigger'] | NullUndefined
// At present in all scenarios it can be supported.
isInSelf: RoamPointerChecker
// Required if clipping is supported
isInClip?: RoamPointerChecker
}
api: ExtensionAPI
zoomOnMouseWheel?: boolean | 'ctrl' | 'shift' | 'alt'
moveOnMouseMove?: boolean | 'ctrl' | 'shift' | 'alt'
moveOnMouseWheel?: boolean | 'ctrl' | 'shift' | 'alt'
/**
* If fixed the page when pan
*/
preventDefaultMouseMove?: boolean
}
type RoamSetting = Omit<Required<RoamOption>, 'zInfo'> & {
zInfoParsed: {
component: Component
z: number
zlevel: number
z2: number
}
};
type RoamEventType = keyof RoamEventParams;
type RoamBehavior = 'zoomOnMouseWheel' | 'moveOnMouseMove' | 'moveOnMouseWheel';
export interface RoamEventParams {
'zoom': {
scale: number
originX: number
originY: number
isAvailableBehavior: Bind3<typeof isAvailableBehavior, null, RoamBehavior, ZRElementEvent>
}
'scrollMove': {
scrollDelta: number
originX: number
originY: number
isAvailableBehavior: Bind3<typeof isAvailableBehavior, null, RoamBehavior, ZRElementEvent>
}
'pan': {
dx: number
dy: number
oldX: number
oldY: number
newX: number
newY: number
isAvailableBehavior: Bind3<typeof isAvailableBehavior, null, RoamBehavior, ZRElementEvent>
}
};
export type RoamEventDefinition = {
[key in keyof RoamEventParams]: (params: RoamEventParams[key]) => void | undefined
};
type RoamPointerChecker = (e: ZRElementEvent, x: number, y: number) => boolean;
/**
* An manager of zoom and pan(darg) hehavior.
* But it is not responsible for updating the view, since view updates vary and can
* not be handled in a uniform way.
*
* Note: regarding view updates:
* - Transformabe views typically use `coord/View` (e.g., geo and series.graph roaming).
* Some commonly used view update logic has been organized into `roamHelper.ts`.
* - Non-transformable views handle updates themselves, possibly involving re-layout,
* (e.g., treemap).
* - Some scenarios do not require transformation (e.g., dataZoom roaming for cartesian,
* brush component).
*/
class RoamController extends Eventful<RoamEventDefinition> {
private _zr: ZRenderType;
private _opt: Required<RoamSetting>;
private _dragging: boolean;
private _pinching: boolean;
private _x: number;
private _y: number;
private _controlType: RoamOptionMixin['roam'];
private _enabled: boolean;
readonly enable: (this: this, controlType: RoamOptionMixin['roam'], opt: RoamOption) => void;
readonly disable: () => void;
constructor(zr: ZRenderType) {
super();
this._zr = zr;
// Avoid two roamController bind the same handler
const mousedownHandler = bind(this._mousedownHandler, this);
const mousemoveHandler = bind(this._mousemoveHandler, this);
const mouseupHandler = bind(this._mouseupHandler, this);
const mousewheelHandler = bind(this._mousewheelHandler, this);
const pinchHandler = bind(this._pinchHandler, this);
/**
* Notice:
* - only enable needed types. For example, if 'zoom'
* is not needed, 'zoom' should not be enabled, otherwise
* default mousewheel behaviour (scroll page) will be disabled.
* - This method is idempotent.
*/
this.enable = function (controlType, rawOpt) {
const zInfo = rawOpt.zInfo;
const {z, zlevel} = retrieveZInfo(zInfo.component);
const zInfoParsed = {
component: zInfo.component,
z,
zlevel,
// By default roam controller is the lowest z2 comparing to other elememts in a component.
z2: retrieve2(zInfo.z2, -Infinity),
};
const triggerInfo = extend({}, rawOpt.triggerInfo);
this._opt = defaults(extend({}, rawOpt), {
zoomOnMouseWheel: true,
moveOnMouseMove: true,
// By default, wheel do not trigger move.
moveOnMouseWheel: false,
preventDefaultMouseMove: true,
zInfoParsed,
triggerInfo,
});
if (controlType == null) {
controlType = true;
}
// A handy optimization for repeatedly calling `enable` during roaming.
// Assert `disable` is only affected by `controlType`.
if (!this._enabled || this._controlType !== controlType) {
this._enabled = true;
// Disable previous first
this.disable();
if (controlType === true || (controlType === 'move' || controlType === 'pan')) {
addRoamZrListener(zr, 'mousedown', mousedownHandler, zInfoParsed);
addRoamZrListener(zr, 'mousemove', mousemoveHandler, zInfoParsed);
addRoamZrListener(zr, 'mouseup', mouseupHandler, zInfoParsed);
}
if (controlType === true || (controlType === 'scale' || controlType === 'zoom')) {
addRoamZrListener(zr, 'mousewheel', mousewheelHandler, zInfoParsed);
addRoamZrListener(zr, 'pinch', pinchHandler, zInfoParsed);
}
}
};
this.disable = function () {
this._enabled = false;
removeRoamZrListener(zr, 'mousedown', mousedownHandler);
removeRoamZrListener(zr, 'mousemove', mousemoveHandler);
removeRoamZrListener(zr, 'mouseup', mouseupHandler);
removeRoamZrListener(zr, 'mousewheel', mousewheelHandler);
removeRoamZrListener(zr, 'pinch', pinchHandler);
};
}
isDragging() {
return this._dragging;
}
isPinching() {
return this._pinching;
}
_checkPointer(e: ZRElementEvent, x: number, y: number): boolean {
const opt = this._opt;
const zInfoParsed = opt.zInfoParsed;
if (onIrrelevantElement(e, opt.api, zInfoParsed.component)) {
return false;
};
const triggerInfo = opt.triggerInfo;
const roamTrigger = triggerInfo.roamTrigger;
let inArea = false;
if (roamTrigger === 'global') {
inArea = true;
}
if (!inArea) {
inArea = triggerInfo.isInSelf(e, x, y);
}
if (inArea && triggerInfo.isInClip && !triggerInfo.isInClip(e, x, y)) {
inArea = false;
}
return inArea;
}
private _decideCursorStyle(
e: ZRElementEvent,
x: number,
y: number,
forReverse: boolean,
): string | NullUndefined {
// If this cursor style decision is not strictly consistent with zrender,
// it's fine - zr will set the cursor on the next mousemove.
// This `grab` cursor style should take the lowest precedence. If the hovring element already
// have a cursor, zrender will set it to be non-'default' before entering this handler.
// (note, e.target is never silent, e.topTarget can be silent be irrelevant.)
const target = e.target;
if (!target && this._checkPointer(e, x, y)) {
// To indicate users that this area is draggable, otherwise users probably cannot kwown
// that when hovering out of the shape but still inside the bounding rect.
return 'grab';
}
if (forReverse) {
return target && (target as Displayable).cursor || 'default';
}
}
dispose() {
this.disable();
}
private _mousedownHandler(e: ZRElementEvent) {
if (eventTool.isMiddleOrRightButtonOnMouseUpDown(e)
|| eventConsumed(e)
) {
return;
}
let el = e.target;
while (el) {
if (el.draggable) {
return;
}
// check if host is draggable
el = el.__hostTarget || el.parent;
}
const x = e.offsetX;
const y = e.offsetY;
// To determine dragging start, only by checking on mosedown, but not mousemove.
// Mouse can be out of target when mouse moving.
if (this._checkPointer(e, x, y)) {
this._x = x;
this._y = y;
this._dragging = true;
}
}
private _mousemoveHandler(e: ZRElementEvent) {
const zr = this._zr;
if (e.gestureEvent === 'pinch'
|| interactionMutex.isTaken(zr, 'globalPan')
|| eventConsumed(e)
) {
return;
}
const x = e.offsetX;
const y = e.offsetY;
if (!this._dragging
|| !isAvailableBehavior('moveOnMouseMove', e, this._opt)
) {
const cursorStyle = this._decideCursorStyle(e, x, y, false);
if (cursorStyle) {
zr.setCursorStyle(cursorStyle);
}
return;
}
zr.setCursorStyle('grabbing');
const oldX = this._x;
const oldY = this._y;
const dx = x - oldX;
const dy = y - oldY;
this._x = x;
this._y = y;
if (this._opt.preventDefaultMouseMove) {
eventTool.stop(e.event);
}
(e as RoamControllerZREventExtend).__ecRoamConsumed = true;
trigger(this, 'pan', 'moveOnMouseMove', e, {
dx: dx, dy: dy, oldX: oldX, oldY: oldY, newX: x, newY: y, isAvailableBehavior: null
});
}
private _mouseupHandler(e: ZRElementEvent) {
if (eventConsumed(e)) {
return;
}
const zr = this._zr;
if (!eventTool.isMiddleOrRightButtonOnMouseUpDown(e)) {
this._dragging = false;
const cursorStyle = this._decideCursorStyle(e, e.offsetX, e.offsetY, true);
if (cursorStyle) {
zr.setCursorStyle(cursorStyle);
}
}
}
private _mousewheelHandler(e: ZRElementEvent) {
if (eventConsumed(e)) {
return;
}
const shouldZoom = isAvailableBehavior('zoomOnMouseWheel', e, this._opt);
const shouldMove = isAvailableBehavior('moveOnMouseWheel', e, this._opt);
const wheelDelta = e.wheelDelta;
const absWheelDeltaDelta = Math.abs(wheelDelta);
const originX = e.offsetX;
const originY = e.offsetY;
// wheelDelta maybe -0 in chrome mac.
if (wheelDelta === 0 || (!shouldZoom && !shouldMove)) {
return;
}
// If both `shouldZoom` and `shouldMove` is true, trigger
// their event both, and the final behavior is determined
// by event listener themselves.
if (shouldZoom) {
// Convenience:
// Mac and VM Windows on Mac: scroll up: zoom out.
// Windows: scroll up: zoom in.
// FIXME: Should do more test in different environment.
// wheelDelta is too complicated in difference nvironment
// (https://developer.mozilla.org/en-US/docs/Web/Events/mousewheel),
// although it has been normallized by zrender.
// wheelDelta of mouse wheel is bigger than touch pad.
const factor = absWheelDeltaDelta > 3 ? 1.4 : absWheelDeltaDelta > 1 ? 1.2 : 1.1;
const scale = wheelDelta > 0 ? factor : 1 / factor;
this._checkTriggerMoveZoom(this, 'zoom', 'zoomOnMouseWheel', e, {
scale: scale, originX: originX, originY: originY, isAvailableBehavior: null
});
}
if (shouldMove) {
// FIXME: Should do more test in different environment.
const absDelta = Math.abs(wheelDelta);
// wheelDelta of mouse wheel is bigger than touch pad.
const scrollDelta = (wheelDelta > 0 ? 1 : -1) * (absDelta > 3 ? 0.4 : absDelta > 1 ? 0.15 : 0.05);
this._checkTriggerMoveZoom(this, 'scrollMove', 'moveOnMouseWheel', e, {
scrollDelta: scrollDelta, originX: originX, originY: originY, isAvailableBehavior: null
});
}
}
private _pinchHandler(e: ZRElementEvent) {
if (interactionMutex.isTaken(this._zr, 'globalPan')
|| eventConsumed(e)
) {
return;
}
const scale = e.pinchScale > 1 ? 1.1 : 1 / 1.1;
this._checkTriggerMoveZoom(this, 'zoom', null, e, {
scale: scale, originX: e.pinchX, originY: e.pinchY, isAvailableBehavior: null
});
}
private _checkTriggerMoveZoom<T extends 'scrollMove' | 'zoom'>(
controller: RoamController,
eventName: T,
behaviorToCheck: RoamBehavior,
e: ZRElementEvent,
contollerEvent: RoamEventParams[T]
) {
if (controller._checkPointer(e, contollerEvent.originX, contollerEvent.originY)) {
// When mouse is out of roamController rect,
// default befavoius should not be be disabled, otherwise
// page sliding is disabled, contrary to expectation.
eventTool.stop(e.event);
(e as RoamControllerZREventExtend).__ecRoamConsumed = true;
trigger(controller, eventName, behaviorToCheck, e, contollerEvent);
}
}
}
type RoamControllerZREventExtend = ZRElementEvent & {
__ecRoamConsumed: boolean
};
function eventConsumed(e: ZRElementEvent): boolean {
return (e as RoamControllerZREventExtend).__ecRoamConsumed;
}
type RoamControllerZREventListener = (e: ZRElementEvent) => void;
type RoamControllerZREventType = 'mousedown' | 'mousemove' | 'mouseup' | 'mousewheel' | 'pinch';
type RoamControllerListenerItem = {listener: RoamControllerZREventListener} & Pick<RoamSetting, 'zInfoParsed'>;
const innerZrStore = makeInner<{
roam: Partial<Record<RoamControllerZREventType, RoamControllerListenerItem[]>>
uniform: Partial<Record<RoamControllerZREventType, RoamControllerZREventListener>>
}, ZRenderType>();
function ensureZrStore(zr: ZRenderType) {
const store = innerZrStore(zr);
store.roam = store.roam || {};
store.uniform = store.uniform || {};
return store;
}
/**
* Listeners are sorted by z2/z/zlevel in descending order.
* This decides the precedence between different roam controllers if they are overlapped.
*
* [MEMO]: It's not easy to perfectly reconcile the conflicts caused by overlap.
* - Consider cases:
* - Multiple roam controllers overlapped.
* - Usually only the topmost can trigger roam.
* - Roam controllers overlap with other zr elements:
* - zr elements are relevant or irrelevent to the host of the roam controller. e.g., axis split line
* or series elements is relevant to a cartesian and should trigger roam.
* - zr elements is above or below the roam controller host, which affects the precedence of interaction.
* - zr elements may not silent only for triggering tooltip by hovering, which is available to roam;
* or may not silent for click, where roam is not preferable.
* - Approach - `addRoamZrListener+pointerChecker+onIrrelevantElement` (currently used):
* - Resolve the precedence between different roam controllers
* - But cannot prevent the handling on other zr elements that under the roam controller in z-order.
* - Approach - "use an invisible zr elements to receive the zr events to trigger roam":
* - More complicated in impl.
* - May cause bad cases where zr event cannot be receive due to other non-silient zr elements covering it.
*/
function addRoamZrListener(
zr: ZRenderType,
eventType: RoamControllerZREventType,
listener: RoamControllerZREventListener,
zInfoParsed: RoamSetting['zInfoParsed']
): void {
const store = ensureZrStore(zr);
const roam = store.roam;
const listenerList = roam[eventType] = roam[eventType] || [];
let idx = 0;
for (; idx < listenerList.length; idx++) {
const currZInfo = listenerList[idx].zInfoParsed;
if (
((currZInfo.zlevel - zInfoParsed.zlevel)
|| (currZInfo.z - zInfoParsed.z)
|| (currZInfo.z2 - zInfoParsed.z2)
// If all equals, the latter added one has a higher precedence.
) <= 0
) {
break;
}
}
listenerList.splice(idx, 0, {listener, zInfoParsed});
ensureUniformListener(zr, eventType);
}
function removeRoamZrListener(
zr: ZRenderType, eventType: RoamControllerZREventType, listener: RoamControllerZREventListener
): void {
const store = ensureZrStore(zr);
const listenerList = store.roam[eventType] || [];
for (let idx = 0; idx < listenerList.length; idx++) {
if (listenerList[idx].listener === listener) {
listenerList.splice(idx, 1);
if (!listenerList.length) {
removeUniformListener(zr, eventType);
}
return;
}
}
}
function ensureUniformListener(zr: ZRenderType, eventType: RoamControllerZREventType): void {
const store = ensureZrStore(zr);
if (!store.uniform[eventType]) {
zr.on(eventType, store.uniform[eventType] = function (event) {
const listenerList = store.roam[eventType];
if (listenerList) {
for (let i = 0; i < listenerList.length; i++) {
listenerList[i].listener(event);
}
}
});
}
}
function removeUniformListener(zr: ZRenderType, eventType: RoamControllerZREventType): void {
const store = ensureZrStore(zr);
const uniform = store.uniform;
if (uniform[eventType]) {
zr.off(eventType, uniform[eventType]);
uniform[eventType] = null;
}
}
function trigger<T extends RoamEventType>(
controller: RoamController,
eventName: T,
behaviorToCheck: RoamBehavior,
e: ZRElementEvent,
contollerEvent: RoamEventParams[T]
) {
// Also provide behavior checker for event listener, for some case that
// multiple components share one listener.
contollerEvent.isAvailableBehavior = bind(isAvailableBehavior, null, behaviorToCheck, e);
// TODO should not have type issue.
(controller as any).trigger(eventName, contollerEvent);
}
// settings: {
// zoomOnMouseWheel
// moveOnMouseMove
// moveOnMouseWheel
// }
// The value can be: true / false / 'shift' / 'ctrl' / 'alt'.
function isAvailableBehavior(
behaviorToCheck: RoamBehavior,
e: ZRElementEvent,
settings: Pick<RoamOption, RoamBehavior>
) {
const setting = settings[behaviorToCheck];
return !behaviorToCheck || (
setting && (!isString(setting) || e.event[setting + 'Key' as 'shiftKey' | 'ctrlKey' | 'altKey'])
);
}
export default RoamController;