xref: /aosp_15_r20/external/perfetto/ui/src/frontend/pan_and_zoom_handler.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2018 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {DisposableStack} from '../base/disposable_stack';
16import {currentTargetOffset, elementIsEditable} from '../base/dom_utils';
17import {raf} from '../core/raf_scheduler';
18import {Animation} from './animation';
19import {DragGestureHandler} from '../base/drag_gesture_handler';
20
21// When first starting to pan or zoom, move at least this many units.
22const INITIAL_PAN_STEP_PX = 50;
23const INITIAL_ZOOM_STEP = 0.1;
24
25// The snappiness (spring constant) of pan and zoom animations [0..1].
26const SNAP_FACTOR = 0.4;
27
28// How much the velocity of a pan or zoom animation increases per millisecond.
29const ACCELERATION_PER_MS = 1 / 50;
30
31// The default duration of a pan or zoom animation. The animation may run longer
32// if the user keeps holding the respective button down or shorter if the button
33// is released. This value so chosen so that it is longer than the typical key
34// repeat timeout to avoid breaks in the animation.
35const DEFAULT_ANIMATION_DURATION = 700;
36
37// The minimum number of units to pan or zoom per frame (before the
38// ACCELERATION_PER_MS multiplier is applied).
39const ZOOM_RATIO_PER_FRAME = 0.008;
40const KEYBOARD_PAN_PX_PER_FRAME = 8;
41
42// Scroll wheel animation steps.
43const HORIZONTAL_WHEEL_PAN_SPEED = 1;
44const WHEEL_ZOOM_SPEED = -0.02;
45
46const EDITING_RANGE_CURSOR = 'ew-resize';
47const DRAG_CURSOR = 'default';
48const PAN_CURSOR = 'move';
49
50// Use key mapping based on the 'KeyboardEvent.code' property vs the
51// 'KeyboardEvent.key', because the former corresponds to the physical key
52// position rather than the glyph printed on top of it, and is unaffected by
53// the user's keyboard layout.
54// For example, 'KeyW' always corresponds to the key at the physical location of
55// the 'w' key on an English QWERTY keyboard, regardless of the user's keyboard
56// layout, or at least the layout they have configured in their OS.
57// Seeing as most users use the keys in the English QWERTY "WASD" position for
58// controlling kb+mouse applications like games, it's a good bet that these are
59// the keys most poeple are going to find natural for navigating the UI.
60// See https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system
61export enum KeyMapping {
62  KEY_PAN_LEFT = 'KeyA',
63  KEY_PAN_RIGHT = 'KeyD',
64  KEY_ZOOM_IN = 'KeyW',
65  KEY_ZOOM_OUT = 'KeyS',
66}
67
68enum Pan {
69  None = 0,
70  Left = -1,
71  Right = 1,
72}
73function keyToPan(e: KeyboardEvent): Pan {
74  if (e.code === KeyMapping.KEY_PAN_LEFT) return Pan.Left;
75  if (e.code === KeyMapping.KEY_PAN_RIGHT) return Pan.Right;
76  return Pan.None;
77}
78
79enum Zoom {
80  None = 0,
81  In = 1,
82  Out = -1,
83}
84function keyToZoom(e: KeyboardEvent): Zoom {
85  if (e.code === KeyMapping.KEY_ZOOM_IN) return Zoom.In;
86  if (e.code === KeyMapping.KEY_ZOOM_OUT) return Zoom.Out;
87  return Zoom.None;
88}
89
90/**
91 * Enables horizontal pan and zoom with mouse-based drag and WASD navigation.
92 */
93export class PanAndZoomHandler implements Disposable {
94  private mousePositionX: number | null = null;
95  private boundOnMouseMove = this.onMouseMove.bind(this);
96  private boundOnWheel = this.onWheel.bind(this);
97  private boundOnKeyDown = this.onKeyDown.bind(this);
98  private boundOnKeyUp = this.onKeyUp.bind(this);
99  private shiftDown = false;
100  private panning: Pan = Pan.None;
101  private panOffsetPx = 0;
102  private targetPanOffsetPx = 0;
103  private zooming: Zoom = Zoom.None;
104  private zoomRatio = 0;
105  private targetZoomRatio = 0;
106  private panAnimation = new Animation(this.onPanAnimationStep.bind(this));
107  private zoomAnimation = new Animation(this.onZoomAnimationStep.bind(this));
108
109  private element: HTMLElement;
110  private onPanned: (movedPx: number) => void;
111  private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void;
112  private editSelection: (currentPx: number) => boolean;
113  private onSelection: (
114    dragStartX: number,
115    dragStartY: number,
116    prevX: number,
117    currentX: number,
118    currentY: number,
119    editing: boolean,
120  ) => void;
121  private endSelection: (edit: boolean) => void;
122  private trash: DisposableStack;
123
124  constructor({
125    element,
126    onPanned,
127    onZoomed,
128    editSelection,
129    onSelection,
130    endSelection,
131  }: {
132    element: HTMLElement;
133    onPanned: (movedPx: number) => void;
134    onZoomed: (zoomPositionPx: number, zoomRatio: number) => void;
135    editSelection: (currentPx: number) => boolean;
136    onSelection: (
137      dragStartX: number,
138      dragStartY: number,
139      prevX: number,
140      currentX: number,
141      currentY: number,
142      editing: boolean,
143    ) => void;
144    endSelection: (edit: boolean) => void;
145  }) {
146    this.element = element;
147    this.onPanned = onPanned;
148    this.onZoomed = onZoomed;
149    this.editSelection = editSelection;
150    this.onSelection = onSelection;
151    this.endSelection = endSelection;
152    this.trash = new DisposableStack();
153
154    document.body.addEventListener('keydown', this.boundOnKeyDown);
155    document.body.addEventListener('keyup', this.boundOnKeyUp);
156    this.element.addEventListener('mousemove', this.boundOnMouseMove);
157    this.element.addEventListener('wheel', this.boundOnWheel, {passive: true});
158    this.trash.defer(() => {
159      this.element.removeEventListener('wheel', this.boundOnWheel);
160      this.element.removeEventListener('mousemove', this.boundOnMouseMove);
161      document.body.removeEventListener('keyup', this.boundOnKeyUp);
162      document.body.removeEventListener('keydown', this.boundOnKeyDown);
163    });
164
165    let prevX = -1;
166    let dragStartX = -1;
167    let dragStartY = -1;
168    let edit = false;
169    this.trash.use(
170      new DragGestureHandler(
171        this.element,
172        (x, y) => {
173          if (this.shiftDown) {
174            this.onPanned(prevX - x);
175          } else {
176            this.onSelection(dragStartX, dragStartY, prevX, x, y, edit);
177          }
178          prevX = x;
179        },
180        (x, y) => {
181          prevX = x;
182          dragStartX = x;
183          dragStartY = y;
184          edit = this.editSelection(x);
185          // Set the cursor style based on where the cursor is when the drag
186          // starts.
187          if (edit) {
188            this.element.style.cursor = EDITING_RANGE_CURSOR;
189          } else if (!this.shiftDown) {
190            this.element.style.cursor = DRAG_CURSOR;
191          }
192        },
193        () => {
194          // Reset the cursor now the drag has ended.
195          this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR;
196          dragStartX = -1;
197          dragStartY = -1;
198          this.endSelection(edit);
199        },
200      ),
201    );
202  }
203
204  [Symbol.dispose]() {
205    this.trash.dispose();
206  }
207
208  private onPanAnimationStep(msSinceStartOfAnimation: number) {
209    const step = (this.targetPanOffsetPx - this.panOffsetPx) * SNAP_FACTOR;
210    if (this.panning !== Pan.None) {
211      const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS;
212      // Pan at least as fast as the snapping animation to avoid a
213      // discontinuity.
214      const targetStep = Math.max(KEYBOARD_PAN_PX_PER_FRAME * velocity, step);
215      this.targetPanOffsetPx += this.panning * targetStep;
216    }
217    this.panOffsetPx += step;
218    if (Math.abs(step) > 1e-1) {
219      this.onPanned(step);
220    } else {
221      this.panAnimation.stop();
222    }
223  }
224
225  private onZoomAnimationStep(msSinceStartOfAnimation: number) {
226    if (this.mousePositionX === null) return;
227    const step = (this.targetZoomRatio - this.zoomRatio) * SNAP_FACTOR;
228    if (this.zooming !== Zoom.None) {
229      const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS;
230      // Zoom at least as fast as the snapping animation to avoid a
231      // discontinuity.
232      const targetStep = Math.max(ZOOM_RATIO_PER_FRAME * velocity, step);
233      this.targetZoomRatio += this.zooming * targetStep;
234    }
235    this.zoomRatio += step;
236    if (Math.abs(step) > 1e-6) {
237      this.onZoomed(this.mousePositionX, step);
238    } else {
239      this.zoomAnimation.stop();
240    }
241  }
242
243  private onMouseMove(e: MouseEvent) {
244    this.mousePositionX = currentTargetOffset(e).x;
245
246    // Only change the cursor when hovering, the DragGestureHandler handles
247    // changing the cursor during drag events. This avoids the problem of
248    // the cursor flickering between styles if you drag fast and get too
249    // far from the current time range.
250    if (e.buttons === 0) {
251      if (this.editSelection(this.mousePositionX)) {
252        this.element.style.cursor = EDITING_RANGE_CURSOR;
253      } else {
254        this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR;
255      }
256    }
257  }
258
259  private onWheel(e: WheelEvent) {
260    if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
261      this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED);
262      raf.scheduleCanvasRedraw();
263    } else if (e.ctrlKey && this.mousePositionX !== null) {
264      const sign = e.deltaY < 0 ? -1 : 1;
265      const deltaY = sign * Math.log2(1 + Math.abs(e.deltaY));
266      this.onZoomed(this.mousePositionX, deltaY * WHEEL_ZOOM_SPEED);
267      raf.scheduleCanvasRedraw();
268    }
269  }
270
271  // Due to a bug in chrome, we get onKeyDown events fired where the payload is
272  // not a KeyboardEvent when selecting an item from an autocomplete suggestion.
273  // See https://issues.chromium.org/issues/41425904
274  // Thus, we can't assume we get an KeyboardEvent and must check manually.
275  private onKeyDown(e: Event) {
276    if (e instanceof KeyboardEvent) {
277      if (elementIsEditable(e.target)) return;
278
279      this.updateShift(e.shiftKey);
280
281      if (e.ctrlKey || e.metaKey) return;
282
283      if (keyToPan(e) !== Pan.None) {
284        if (this.panning !== keyToPan(e)) {
285          this.panAnimation.stop();
286          this.panOffsetPx = 0;
287          this.targetPanOffsetPx = keyToPan(e) * INITIAL_PAN_STEP_PX;
288        }
289        this.panning = keyToPan(e);
290        this.panAnimation.start(DEFAULT_ANIMATION_DURATION);
291      }
292
293      if (keyToZoom(e) !== Zoom.None) {
294        if (this.zooming !== keyToZoom(e)) {
295          this.zoomAnimation.stop();
296          this.zoomRatio = 0;
297          this.targetZoomRatio = keyToZoom(e) * INITIAL_ZOOM_STEP;
298        }
299        this.zooming = keyToZoom(e);
300        this.zoomAnimation.start(DEFAULT_ANIMATION_DURATION);
301      }
302    }
303  }
304
305  private onKeyUp(e: Event) {
306    if (e instanceof KeyboardEvent) {
307      this.updateShift(e.shiftKey);
308
309      if (e.ctrlKey || e.metaKey) return;
310
311      if (keyToPan(e) === this.panning) {
312        this.panning = Pan.None;
313      }
314      if (keyToZoom(e) === this.zooming) {
315        this.zooming = Zoom.None;
316      }
317    }
318  }
319
320  // TODO(hjd): Move this shift handling into the viewer page.
321  private updateShift(down: boolean) {
322    if (down === this.shiftDown) return;
323    this.shiftDown = down;
324    if (this.shiftDown) {
325      this.element.style.cursor = PAN_CURSOR;
326    } else if (this.mousePositionX !== null) {
327      this.element.style.cursor = DRAG_CURSOR;
328    }
329  }
330}
331