Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 68 additions & 9 deletions src/component/helper/RoamController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,12 @@ export type RoamEventDefinition = {
type RoamPointerChecker = (e: ZRElementEvent, x: number, y: number) => boolean;

/**
* An manager of zoom and pan(darg) hehavior.
* A manager of zoom and pan(drag) behavior.
* 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).
* - Transformable 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).
Expand All @@ -132,6 +132,14 @@ class RoamController extends Eventful<RoamEventDefinition> {

private _y: number;

private _pinchX: number;

private _pinchY: number;

private _moveEnabled: boolean;

private _zoomEnabled: boolean;

private _controlType: RoamOptionMixin['roam'];

private _enabled: boolean;
Expand Down Expand Up @@ -187,6 +195,8 @@ class RoamController extends Eventful<RoamEventDefinition> {
if (controlType == null) {
controlType = true;
}
const moveEnabled = controlType === true || (controlType === 'move' || controlType === 'pan');
const zoomEnabled = controlType === true || (controlType === 'scale' || controlType === 'zoom');

// A quick optimization for repeatedly calling `enable` during roaming.
// Assert `disable` is only affected by `controlType`.
Expand All @@ -195,16 +205,20 @@ class RoamController extends Eventful<RoamEventDefinition> {
this.disable();

this._enabled = true;
if (controlType === true || (controlType === 'move' || controlType === 'pan')) {
if (moveEnabled) {
addRoamZrListener(zr, 'mousedown', mousedownHandler, zInfoParsed);
addRoamZrListener(zr, 'mousemove', mousemoveHandler, zInfoParsed);
addRoamZrListener(zr, 'mouseup', mouseupHandler, zInfoParsed);
}
if (controlType === true || (controlType === 'scale' || controlType === 'zoom')) {
if (zoomEnabled) {
addRoamZrListener(zr, 'mousewheel', mousewheelHandler, zInfoParsed);
}
if (moveEnabled || zoomEnabled) {
addRoamZrListener(zr, 'pinch', pinchHandler, zInfoParsed);
}
}
this._moveEnabled = moveEnabled;
this._zoomEnabled = zoomEnabled;
Comment on lines 219 to +221

@NightRa NightRa Jul 30, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, you’re correct that _controlType is never assigned, so the existing idempotency check always removes and re-adds the listeners, but this behavior predates this PR.

Assigning _controlType alone may not be safe, however. Listener registration also captures component, zlevel, z, and z2, which determine precedence between overlapping controllers. These values are recalculated by every enable() call; skipping registration solely because controlType is unchanged could leave stale ordering metadata when a component’s z-order changes.

A complete fix should invalidate or update the registration when either controlType or that z-order metadata changes. Since this is an existing issue rather than a regression from the pinch changes, I suggest handling it separately.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@plainheart / @100pah - do you want me to implement this as well here? It's a bit out of scope and will make the PR quite larger

};

this.disable = function () {
Expand All @@ -215,6 +229,8 @@ class RoamController extends Eventful<RoamEventDefinition> {
removeRoamZrListener(zr, 'mouseup', mouseupHandler);
removeRoamZrListener(zr, 'mousewheel', mousewheelHandler);
removeRoamZrListener(zr, 'pinch', pinchHandler);
this._moveEnabled = false;
this._zoomEnabled = false;
}
};
}
Expand Down Expand Up @@ -355,6 +371,7 @@ class RoamController extends Eventful<RoamEventDefinition> {
const zr = this._zr;
if (!eventTool.isMiddleOrRightButtonOnMouseUpDown(e)) {
this._dragging = false;
this._pinching = false;

const cursorStyle = this._decideCursorStyle(e, e.offsetX, e.offsetY, true);
if (cursorStyle) {
Expand Down Expand Up @@ -418,10 +435,52 @@ class RoamController extends Eventful<RoamEventDefinition> {
) {
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
});
const originX = e.pinchX;
const originY = e.pinchY;

// Gate only the beginning of a pinch capture.
// Once captured, keep handling movement outside the roam area.
// Requiring a native touchstart for a new capture also
// prevents another RoamController from picking up an already-moving pinch.
const isTouchStart = e.event.type === 'touchstart';
const isPinchStart = !this._pinching || isTouchStart;
if (isPinchStart) {
if (!isTouchStart || !this._checkPointer(e, originX, originY)) {
return;
}
}
Comment on lines +441 to +451

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ZRender does emit a pinch during touchstart once the event contains two touches. GestureMgr falls back to using the current sample as pinchPre, producing pinchScale === 1, and Handler.processGesture dispatches that event while the native event type remains touchstart.

I verified this against ZRender 6.1.0 and the current ZRender source.

Requiring touchstart here is intentional: it ensures pinch ownership is decided only when the gesture begins. Allowing an uncaptured controller to start on touchmove would let a pinch be acquired after entering another roam area, potentially causing unexpected mid-gesture zoom or transferring ownership between overlapping controllers.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And empirically this works well too - you may take a look at the attached videos


eventTool.stop(e.event);
(e as RoamControllerZREventExtend).__ecRoamConsumed = true;

this._pinching = true;

const oldX = this._pinchX;
const oldY = this._pinchY;
this._pinchX = originX;
this._pinchY = originY;

if (!isPinchStart
&& this._moveEnabled
&& (originX !== oldX || originY !== oldY)
) {
trigger(this, 'pan', null, e, {
dx: originX - oldX,
dy: originY - oldY,
oldX: oldX,
oldY: oldY,
newX: originX,
newY: originY,
isAvailableBehavior: null
});
}

const scale = e.pinchScale;
if (this._zoomEnabled && scale !== 1) {
trigger(this, 'zoom', null, e, {
scale: scale, originX: originX, originY: originY, isAvailableBehavior: null
});
}
}

private _checkTriggerMoveZoom<T extends 'scrollMove' | 'zoom'>(
Expand Down Expand Up @@ -586,4 +645,4 @@ function isAvailableBehavior(
);
}

export default RoamController;
export default RoamController;