perfetto-ui: Preperation for adding counter details

A couple of preparatory changes for adding details for counters:

- Fix a bug where drag_gesture_handler missed out of window mouseup
  and ended up in a bad state.
- Optimise counter rendering for the common case where counter value does
  not change.
- Highlight location of data point on counter track.
- Add a resizeable details window.

Change-Id: I9784f92e79fc812903bc1a235c76d66b1cf20010
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index ebb2eed..0c95dfe 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -126,6 +126,8 @@
 .page {
     grid-area: page;
     position: relative;
+    display: flex;
+    flex-direction: column
 }
 
 .alerts {
@@ -152,6 +154,7 @@
 .home-page {
     text-align: center;
     padding-top: 20vh;
+    align-items: center;
 }
 
 .home-page .logo {
@@ -327,12 +330,24 @@
 }
 
 .pan-and-zoom-content {
-  height: 100%;
+  flex: 1;
   position: relative;
   display: flex;
   flex-flow: column nowrap;
 }
 
+.details-content {
+  .handle {
+    background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAFCAYAAACeuGYRAAAAFUlEQVR42mPYvXv3f1IxA1lgcNsEAEynfFEloq9lAAAAAElFTkSuQmCC) 0 0 no-repeat; // http://
+    background-color: #fafafa;
+    background-position: center center;
+    border: 1px solid rgba(0,0,0,0.1);
+    cursor: row-resize;
+    height: 12px;
+    min-height: 12px;
+  }
+}
+
 .overview-timeline {
   height: 100px;
 }
diff --git a/ui/src/base/binary_search.ts b/ui/src/base/binary_search.ts
new file mode 100644
index 0000000..e81a4a7
--- /dev/null
+++ b/ui/src/base/binary_search.ts
@@ -0,0 +1,49 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed 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.
+
+
+function searchImpl(
+    haystack: Float64Array, needle: number, i: number, j: number): number {
+  if (i === j) return -1;
+  if (i + 1 === j) {
+    return (needle >= haystack[i]) ? i : -1;
+  }
+
+  const mid = Math.floor((j - i) / 2) + i;
+  const midValue = haystack[mid];
+  if (needle < midValue) {
+    return searchImpl(haystack, needle, i, mid);
+  } else {
+    return searchImpl(haystack, needle, mid, j);
+  }
+}
+
+export function search(haystack: Float64Array, needle: number): number {
+  return searchImpl(haystack, needle, 0, haystack.length);
+}
+
+
+export function searchSegment(
+    haystack: Float64Array, needle: number): [number, number] {
+  if (!haystack.length) return [-1, -1];
+
+  const left = search(haystack, needle);
+  if (left === -1) {
+    return [left, 0];
+  } else if (left + 1 === haystack.length) {
+    return [left, -1];
+  } else {
+    return [left, left + 1];
+  }
+}
diff --git a/ui/src/base/binary_search_unittest.ts b/ui/src/base/binary_search_unittest.ts
new file mode 100644
index 0000000..ee653ae
--- /dev/null
+++ b/ui/src/base/binary_search_unittest.ts
@@ -0,0 +1,40 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed 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 {search, searchSegment} from './binary_search';
+
+test('binarySearch', () => {
+  expect(search(Float64Array.of(), 100)).toEqual(-1);
+  expect(search(Float64Array.of(42), 42)).toEqual(0);
+  expect(search(Float64Array.of(42), 43)).toEqual(0);
+  expect(search(Float64Array.of(42), 41)).toEqual(-1);
+  expect(search(Float64Array.of(42, 43), 42)).toEqual(0);
+  expect(search(Float64Array.of(42, 43), 43)).toEqual(1);
+  expect(search(Float64Array.of(42, 43), 44)).toEqual(1);
+  expect(search(Float64Array.of(42, 43, 44), 41)).toEqual(-1);
+  expect(search(Float64Array.of(42, 43, 44), 42)).toEqual(0);
+  expect(search(Float64Array.of(42, 43, 44), 43)).toEqual(1);
+  expect(search(Float64Array.of(42, 43, 44), 44)).toEqual(2);
+  expect(search(Float64Array.of(42, 43, 44), 45)).toEqual(2);
+});
+
+test('searchSegment', () => {
+  expect(searchSegment(Float64Array.of(), 100)).toEqual([-1, -1]);
+
+  expect(searchSegment(Float64Array.of(42), 41)).toEqual([-1, 0]);
+  expect(searchSegment(Float64Array.of(42), 42)).toEqual([0, -1]);
+  expect(searchSegment(Float64Array.of(42), 43)).toEqual([0, -1]);
+
+  expect(searchSegment(Float64Array.of(42, 44), 42)).toEqual([0, 1]);
+});
diff --git a/ui/src/frontend/drag_gesture_handler.ts b/ui/src/frontend/drag_gesture_handler.ts
index 6971d69..8171b18 100644
--- a/ui/src/frontend/drag_gesture_handler.ts
+++ b/ui/src/frontend/drag_gesture_handler.ts
@@ -38,6 +38,9 @@
   }
 
   private onMouseMove(e: MouseEvent) {
+    if (e.buttons === 0) {
+      return this.onMouseUp(e);
+    }
     this.onDrag(
         e.clientX - this.clientRect!.left, e.clientY - this.clientRect!.top);
     e.stopPropagation();
@@ -49,4 +52,4 @@
     this.onDragFinished();
     e.stopPropagation();
   }
-}
\ No newline at end of file
+}
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index d4ac2ec..50af60e 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -162,9 +162,9 @@
   }
 
   private updateCanvasDimensions() {
-    this.canvasHeight = this.attrs.doesScroll ?
-        this.parentHeight * this.canvasOverdrawFactor :
-        this.totalPanelHeight;
+    this.canvasHeight = Math.floor(
+        this.attrs.doesScroll ? this.parentHeight * this.canvasOverdrawFactor :
+                                this.totalPanelHeight);
     const ctx = assertExists(this.ctx);
     const canvas = assertExists(ctx.canvas);
     canvas.style.height = `${this.canvasHeight}px`;
@@ -176,7 +176,8 @@
 
   private repositionCanvas() {
     const canvas = assertExists(assertExists(this.ctx).canvas);
-    const canvasYStart = this.scrollTop - this.getCanvasOverdrawHeightPerSide();
+    const canvasYStart =
+        Math.floor(this.scrollTop - this.getCanvasOverdrawHeightPerSide());
     canvas.style.transform = `translateY(${canvasYStart}px)`;
   }
 
@@ -222,7 +223,7 @@
     if (!this.ctx) return;
     this.ctx.clearRect(0, 0, this.parentWidth, this.canvasHeight);
     const canvasYStart =
-        Math.ceil(this.scrollTop - this.getCanvasOverdrawHeightPerSide());
+        Math.floor(this.scrollTop - this.getCanvasOverdrawHeightPerSide());
 
     let panelYStart = 0;
     const panels = assertExists(this.attrs).panels;
diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts
index 01ee096..de19a83 100644
--- a/ui/src/frontend/track_panel.ts
+++ b/ui/src/frontend/track_panel.ts
@@ -130,7 +130,7 @@
         attrs.track.onMouseOut();
         globals.rafScheduler.scheduleRedraw();
       },
-    }, );
+    });
   }
 }
 
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index 06ab0ff..2da695e 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -18,6 +18,7 @@
 import {TimeSpan} from '../common/time';
 
 import {copyToClipboard} from './clipboard';
+import {DragGestureHandler} from './drag_gesture_handler';
 import {globals} from './globals';
 import {HeaderPanel} from './header_panel';
 import {OverviewTimelinePanel} from './overview_timeline_panel';
@@ -30,8 +31,8 @@
 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 {
   view() {
@@ -85,6 +86,51 @@
   renderCanvas() {}
 }
 
+interface DragHandleAttrs {
+  height: number;
+  resize: (height: number) => void;
+}
+
+class DragHandle implements m.ClassComponent<DragHandleAttrs> {
+  private dragStartHeight = 0;
+  private height = 0;
+  private resize: undefined|((height: number) => void);
+
+  oncreate({dom, attrs}: m.CVnodeDOM<DragHandleAttrs>) {
+    this.resize = attrs.resize;
+    this.height = attrs.height;
+    const elem = dom as HTMLElement;
+    new DragGestureHandler(
+        elem,
+        this.onDrag.bind(this),
+        this.onDragStart.bind(this),
+        this.onDragEnd.bind(this));
+  }
+
+  onupdate({attrs}: m.CVnodeDOM<DragHandleAttrs>) {
+    this.resize = attrs.resize;
+    this.height = attrs.height;
+  }
+
+  onDrag(_x: number, y: number) {
+    if (this.resize) {
+      const newHeight = this.dragStartHeight + (DRAG_HANDLE_HEIGHT_PX / 2) - y;
+      this.resize(Math.floor(newHeight));
+    }
+    globals.rafScheduler.scheduleFullRedraw();
+  }
+
+  onDragStart(_x: number, _y: number) {
+    this.dragStartHeight = this.height;
+  }
+
+  onDragEnd() {}
+
+  view() {
+    return m('.handle');
+  }
+}
+
 /**
  * Top-most level component for the viewer page. Holds tracks, brush timeline,
  * panels, and everything else that's part of the main trace viewer page.
@@ -92,6 +138,7 @@
 class TraceViewer implements m.ClassComponent {
   private onResize: () => void = () => {};
   private zoomContent?: PanAndZoomHandler;
+  private detailsHeight = 100;
 
   oncreate(vnode: m.CVnodeDOM) {
     const frontendLocalState = globals.frontendLocalState;
@@ -194,7 +241,15 @@
           m('.scrolling-panel-container', m(PanelContainer, {
               doesScroll: true,
               panels: scrollingPanels,
-            }))));
+            }))),
+        m('.details-content',
+          {style: {height: `${this.detailsHeight}px`}},
+          m(DragHandle, {
+            resize: (height: number) => {
+              this.detailsHeight = Math.max(height, DRAG_HANDLE_HEIGHT_PX);
+            },
+            height: this.detailsHeight,
+          })));
   }
 }
 
diff --git a/ui/src/tracks/counter/frontend.ts b/ui/src/tracks/counter/frontend.ts
index 88fe587..912b4f1 100644
--- a/ui/src/tracks/counter/frontend.ts
+++ b/ui/src/tracks/counter/frontend.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {searchSegment} from '../../base/binary_search';
 import {assertTrue} from '../../base/logging';
 import {Actions} from '../../common/actions';
 import {TrackState} from '../../common/state';
@@ -45,6 +46,8 @@
   private reqPending = false;
   private mouseXpos = 0;
   private hoveredValue: number|undefined = undefined;
+  private hoveredTs: number|undefined = undefined;
+  private hoveredTsEnd: number|undefined = undefined;
 
   constructor(trackState: TrackState) {
     super(trackState);
@@ -121,13 +124,13 @@
     for (let i = 0; i < data.values.length; i++) {
       const value = data.values[i];
       const startTime = data.timestamps[i];
+      const nextY = zeroY - Math.round((value / yRange) * RECT_HEIGHT);
+      if (nextY === lastY) continue;
 
       lastX = Math.floor(timeScale.timeToPx(startTime));
       ctx.lineTo(lastX, lastY);
-
-      const height = Math.round((value / yRange) * RECT_HEIGHT);
-      lastY = zeroY - height;
-      ctx.lineTo(lastX, lastY);
+      ctx.lineTo(lastX, nextY);
+      lastY = nextY;
     }
     ctx.lineTo(endPx, lastY);
     ctx.lineTo(endPx, zeroY);
@@ -143,20 +146,38 @@
     ctx.lineTo(endPx, zeroY);
     ctx.closePath();
     ctx.stroke();
+    ctx.setLineDash([]);
 
     ctx.font = '10px Google Sans';
 
-    if (this.hoveredValue !== undefined) {
-      // Draw a vertical bar to highlight the mouse cursor.
-      ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
-      const height = Math.round(RECT_HEIGHT * this.hoveredValue / yMax);
-      ctx.fillRect(
-          this.mouseXpos, MARGIN_TOP + RECT_HEIGHT - height, 2, height);
-
+    if (this.hoveredValue !== undefined && this.hoveredTs !== undefined) {
       // TODO(hjd): Add units.
       const text = `value: ${this.hoveredValue.toLocaleString()}`;
       const width = ctx.measureText(text).width;
 
+      ctx.fillStyle = `hsl(${hue}, 45%, 75%)`;
+      ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`;
+
+      const xStart = Math.floor(timeScale.timeToPx(this.hoveredTs));
+      const xEnd = this.hoveredTsEnd === undefined ?
+          endPx :
+          Math.floor(timeScale.timeToPx(this.hoveredTsEnd));
+      const y = zeroY - Math.round((this.hoveredValue / yRange) * RECT_HEIGHT);
+
+      // Highlight line.
+      ctx.beginPath();
+      ctx.moveTo(xStart, y);
+      ctx.lineTo(xEnd, y);
+      ctx.lineWidth = 3;
+      ctx.stroke();
+      ctx.lineWidth = 1;
+
+      // Draw change marker.
+      ctx.beginPath();
+      ctx.arc(xStart, y, 3 /*r*/, 0 /*start angle*/, 2 * Math.PI /*end angle*/);
+      ctx.fill();
+      ctx.stroke();
+
       // Draw the tooltip.
       ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
       ctx.fillRect(this.mouseXpos + 5, MARGIN_TOP, width + 16, RECT_HEIGHT);
@@ -188,15 +209,22 @@
     this.mouseXpos = x;
     const {timeScale} = globals.frontendLocalState;
     const time = timeScale.pxToTime(x);
-    this.hoveredValue = undefined;
-    for (let i = 0; i < data.values.length; i++) {
-      if (data.timestamps[i] > time) break;
-      this.hoveredValue = data.values[i];
-    }
+
+    const [left, right] = searchSegment(data.timestamps, time);
+    this.hoveredTs = left === -1 ? undefined : data.timestamps[left];
+    this.hoveredTsEnd = right === -1 ? undefined : data.timestamps[right];
+    this.hoveredValue = left === -1 ? undefined : data.values[left];
+
+    // for (let i = 0; i < data.values.length; i++) {
+    //  if (data.timestamps[i] > time) break;
+    //  this.hoveredTs = data.timestamps[i];
+    //  this.hoveredValue = data.values[i];
+    //}
   }
 
   onMouseOut() {
     this.hoveredValue = undefined;
+    this.hoveredTs = undefined;
   }
 }