perfetto-ui: Zoom follows mouse

Bug: 122350836
Change-Id: I3d59509bc97b8fbd10aa7454347b26fd353d998f
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index be8e4a2..4dba4d6 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -16,7 +16,7 @@
     --topbar-height: 48px;
     --monospace-font: 'Roboto Mono', monospace;
 
-    // Keep in sync with TRACK_SHELL_WIDTH in track_panel.ts .
+    // Keep in sync with TRACK_SHELL_WIDTH in track_panel.ts
     --track-shell-width: 250px;
 }
 
diff --git a/ui/src/frontend/time_scale.ts b/ui/src/frontend/time_scale.ts
index 0e22955..7444afb 100644
--- a/ui/src/frontend/time_scale.ts
+++ b/ui/src/frontend/time_scale.ts
@@ -14,26 +14,28 @@
 
 import {TimeSpan} from '../common/time';
 
+const MAX_ZOOM_SPAN_SEC = 1e-4;  // 0.1 ms.
+
 /**
- * Defines a mapping between number and Milliseconds for the entire application.
+ * Defines a mapping between number and seconds for the entire application.
  * Linearly scales time values from boundsMs to pixel values in boundsPx and
  * back.
  */
 export class TimeScale {
   private timeBounds: TimeSpan;
-  private startPx: number;
-  private endPx: number;
+  private _startPx: number;
+  private _endPx: number;
   private secPerPx = 0;
 
   constructor(timeBounds: TimeSpan, boundsPx: [number, number]) {
     this.timeBounds = timeBounds;
-    this.startPx = boundsPx[0];
-    this.endPx = boundsPx[1];
+    this._startPx = boundsPx[0];
+    this._endPx = boundsPx[1];
     this.updateSlope();
   }
 
   private updateSlope() {
-    this.secPerPx = this.timeBounds.duration / (this.endPx - this.startPx);
+    this.secPerPx = this.timeBounds.duration / (this._endPx - this._startPx);
   }
 
   deltaTimeToPx(time: number): number {
@@ -41,11 +43,11 @@
   }
 
   timeToPx(time: number): number {
-    return this.startPx + (time - this.timeBounds.start) / this.secPerPx;
+    return this._startPx + (time - this.timeBounds.start) / this.secPerPx;
   }
 
   pxToTime(px: number): number {
-    return this.timeBounds.start + (px - this.startPx) * this.secPerPx;
+    return this.timeBounds.start + (px - this._startPx) * this.secPerPx;
   }
 
   deltaPxToDuration(px: number): number {
@@ -58,8 +60,32 @@
   }
 
   setLimitsPx(pxStart: number, pxEnd: number) {
-    this.startPx = pxStart;
-    this.endPx = pxEnd;
+    this._startPx = pxStart;
+    this._endPx = pxEnd;
     this.updateSlope();
   }
+
+  get startPx(): number {
+    return this._startPx;
+  }
+
+  get endPx(): number {
+    return this._endPx;
+  }
+}
+
+export function computeZoom(
+    scale: TimeScale, span: TimeSpan, zoomFactor: number, zoomPx: number):
+    TimeSpan {
+  const startPx = scale.startPx;
+  const endPx = scale.endPx;
+  const deltaPx = endPx - startPx;
+  const deltaTime = span.end - span.start;
+  const newDeltaTime = Math.max(deltaTime * zoomFactor, MAX_ZOOM_SPAN_SEC);
+  const clampedZoomPx = Math.max(startPx, Math.min(endPx, zoomPx));
+  const zoomTime = scale.pxToTime(clampedZoomPx);
+  const r = (clampedZoomPx - startPx) / deltaPx;
+  const newStartTime = zoomTime - newDeltaTime * r;
+  const newEndTime = newStartTime + newDeltaTime;
+  return new TimeSpan(newStartTime, newEndTime);
 }
diff --git a/ui/src/frontend/time_scale_unittest.ts b/ui/src/frontend/time_scale_unittest.ts
index e8bbdef..ed9fc41 100644
--- a/ui/src/frontend/time_scale_unittest.ts
+++ b/ui/src/frontend/time_scale_unittest.ts
@@ -14,7 +14,7 @@
 
 import {TimeSpan} from '../common/time';
 
-import {TimeScale} from './time_scale';
+import {computeZoom, TimeScale} from './time_scale';
 
 test('time scale to work', () => {
   const scale = new TimeScale(new TimeSpan(0, 100), [200, 1000]);
@@ -44,4 +44,28 @@
   expect(scale.timeToPx(0)).toEqual(200);
   expect(scale.timeToPx(100)).toEqual(600);
   expect(scale.timeToPx(200)).toEqual(1000);
-});
\ No newline at end of file
+});
+
+test('it zooms', () => {
+  const span = new TimeSpan(0, 20);
+  const scale = new TimeScale(span, [0, 100]);
+  const newSpan = computeZoom(scale, span, 0.5, 50);
+  expect(newSpan.start).toEqual(5);
+  expect(newSpan.end).toEqual(15);
+});
+
+test('it zooms an offset scale and span', () => {
+  const span = new TimeSpan(1000, 1020);
+  const scale = new TimeScale(span, [200, 300]);
+  const newSpan = computeZoom(scale, span, 0.5, 250);
+  expect(newSpan.start).toEqual(1005);
+  expect(newSpan.end).toEqual(1015);
+});
+
+test('it clamps zoom in', () => {
+  const span = new TimeSpan(1000, 1040);
+  const scale = new TimeScale(span, [200, 300]);
+  const newSpan = computeZoom(scale, span, 0.0000000001, 225);
+  expect((newSpan.end - newSpan.start) / 2 + newSpan.start).toBeCloseTo(1010);
+  expect(newSpan.end - newSpan.start).toBeCloseTo(1e-4, 8);
+});
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index ba2a98d..085feab 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -27,11 +27,11 @@
 import {Panel} from './panel';
 import {AnyAttrsVnode, PanelContainer} from './panel_container';
 import {TimeAxisPanel} from './time_axis_panel';
+import {computeZoom} from './time_scale';
 import {TrackGroupPanel} from './track_group_panel';
 import {TRACK_SHELL_WIDTH} from './track_panel';
 import {TrackPanel} from './track_panel';
 
-const MAX_ZOOM_SPAN_SEC = 1e-4;  // 0.1 ms.
 const DRAG_HANDLE_HEIGHT_PX = 12;
 
 class QueryTable extends Panel {
@@ -189,16 +189,15 @@
         frontendLocalState.updateVisibleTime(new TimeSpan(tStart, tEnd));
         globals.rafScheduler.scheduleRedraw();
       },
-      onZoomed: (_: number, zoomRatio: number) => {
-        const vizTime = frontendLocalState.visibleWindowTime;
-        const curSpanSec = vizTime.duration;
-        const newSpanSec =
-            Math.max(curSpanSec - curSpanSec * zoomRatio, MAX_ZOOM_SPAN_SEC);
-        const deltaSec = (curSpanSec - newSpanSec) / 2;
-        const newStartSec = vizTime.start + deltaSec;
-        const newEndSec = vizTime.end - deltaSec;
-        frontendLocalState.updateVisibleTime(
-            new TimeSpan(newStartSec, newEndSec));
+      onZoomed: (zoomedPositionPx: number, zoomRatio: number) => {
+        // TODO(hjd): Avoid hardcoding TRACK_SHELL_WIDTH.
+        // TODO(hjd): Improve support for zooming in overview timeline.
+        const span = frontendLocalState.visibleWindowTime;
+        const scale = frontendLocalState.timeScale;
+        const zoomPx = zoomedPositionPx - TRACK_SHELL_WIDTH;
+        const newSpan = computeZoom(scale, span, 1 - zoomRatio, zoomPx);
+        frontendLocalState.updateVisibleTime(newSpan);
+        globals.rafScheduler.scheduleRedraw();
       }
     });
   }