Add zoom rectangles to plotting tool

This makes it so that right-click + drag on the plots draws a rectangle
which we will zoom to.

Change-Id: I968f56151228831b6f54d9f850dc6790e4a79052
diff --git a/aos/network/www/plotter.ts b/aos/network/www/plotter.ts
index bbf5205..4c73a65 100644
--- a/aos/network/www/plotter.ts
+++ b/aos/network/www/plotter.ts
@@ -41,7 +41,7 @@
   private pointAttribLocation: number;
   private colorLocation: WebGLUniformLocation | null;
   private pointSizeLocation: WebGLUniformLocation | null;
-  private _label: string = "";
+  private _label: string|null = null;
   constructor(
       private readonly ctx: WebGLRenderingContext,
       private readonly program: WebGLProgram,
@@ -139,7 +139,7 @@
     this._label = label;
   }
 
-  label(): string {
+  label(): string|null {
     return this._label;
   }
 
@@ -200,6 +200,7 @@
 
 // The button to use for panning the plot.
 const PAN_BUTTON = MouseButton.Left;
+const RECTANGLE_BUTTON = MouseButton.Right;
 
 // Returns the mouse button that generated a given event.
 function transitionButton(event: MouseEvent): MouseButton {
@@ -250,9 +251,6 @@
     // Space between rows of the legend.
     const step = 20;
 
-    // Total height of the body of the legend.
-    const height = step * this.lines.length;
-
     let maxWidth = 0;
 
     // In the legend, we render both a small line of the appropriate color as
@@ -265,12 +263,25 @@
 
     // Calculate how wide the legend needs to be to fit all the text.
     this.ctx.textAlign = 'left';
+    let numLabels = 0;
     for (let line of this.lines) {
+      if (line.label() === null) {
+        continue;
+      }
+      ++numLabels;
       const width =
           textStart + this.ctx.measureText(line.label()).actualBoundingBoxRight;
       maxWidth = Math.max(width, maxWidth);
     }
 
+    if (numLabels === 0) {
+      this.ctx.restore();
+      return;
+    }
+
+    // Total height of the body of the legend.
+    const height = step * numLabels;
+
     // Set the legend background to be white and opaque.
     this.ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
     const backgroundBuffer = 5;
@@ -280,6 +291,9 @@
 
     // Go through each line and render the little lines and text for each Line.
     for (let line of this.lines) {
+      if (line.label() === null) {
+        continue;
+      }
       this.ctx.translate(0, step);
       const color = line.color();
       this.ctx.strokeStyle = `rgb(${255.0 * color[0]}, ${255.0 * color[1]}, ${255.0 * color[2]})`;
@@ -727,9 +741,13 @@
   private canvas = document.createElement('canvas');
   private textCanvas = document.createElement('canvas');
   private drawer: LineDrawer;
-  private static keysPressed: object = {'x': false, 'y': false};
+  private static keysPressed:
+      object = {'x': false, 'y': false, 'Escape': false};
+  // List of all plots to use for propagating key-press events to.
+  private static allPlots: Plot[] = [];
   // In canvas coordinates (the +/-1 square).
-  private lastMousePanPosition: number[] = null;
+  private lastMousePanPosition: number[]|null = null;
+  private rectangleStartPosition: number[]|null = null;
   private axisLabelBuffer: WhitespaceBuffers =
       new WhitespaceBuffers(50, 20, 20, 30);
   private axisLabels: AxisLabels;
@@ -739,6 +757,7 @@
   private linkedXAxes: Plot[] = [];
   private lastTimeMs: number = 0;
   private defaultYRange: number[]|null = null;
+  private zoomRectangle: Line;
 
   constructor(wrapperDiv: HTMLDivElement, width: number, height: number) {
     wrapperDiv.appendChild(this.canvas);
@@ -777,12 +796,15 @@
     this.canvas.onmousemove = (e) => {
       this.handleMouseMove(e);
     };
-    // TODO(james): Deconflict the global state....
+    this.canvas.addEventListener('contextmenu', event => event.preventDefault());
+    // Note: To handle the fact that only one keypress handle can be registered
+    // per browser tab, we share key-press handlers across all plot instances.
+    Plot.allPlots.push(this);
     document.onkeydown = (e) => {
-      this.handleKeyDown(e);
+      Plot.handleKeyDown(e);
     };
     document.onkeyup = (e) => {
-      this.handleKeyUp(e);
+      Plot.handleKeyUp(e);
     };
 
     const textCtx = this.textCanvas.getContext("2d");
@@ -790,6 +812,10 @@
         new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
     this.legend = new Legend(textCtx, this.drawer.getLines());
 
+    this.zoomRectangle = this.getDrawer().addLine();
+    this.zoomRectangle.setColor([1, 1, 1]);
+    this.zoomRectangle.setPointSize(0);
+
     this.draw();
   }
 
@@ -804,6 +830,10 @@
     ];
   }
 
+  mousePlotLocation(event: MouseEvent): number[] {
+    return this.drawer.canvasToPlotCoordinates(this.mouseCanvasLocation(event));
+  }
+
   handleWheel(event: WheelEvent) {
     if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
       return;
@@ -823,17 +853,45 @@
   }
 
   handleMouseDown(event: MouseEvent) {
-    if (transitionButton(event) === PAN_BUTTON) {
-      this.lastMousePanPosition = this.mouseCanvasLocation(event);
+    const button = transitionButton(event);
+    switch (button) {
+      case PAN_BUTTON:
+        this.lastMousePanPosition = this.mouseCanvasLocation(event);
+        break;
+      case RECTANGLE_BUTTON:
+        this.rectangleStartPosition = this.mousePlotLocation(event);
+        break;
+      default:
+        break;
     }
   }
 
   handleMouseUp(event: MouseEvent) {
-    if (transitionButton(event) === PAN_BUTTON) {
-      this.lastMousePanPosition = null;
+    const button = transitionButton(event);
+    switch (button) {
+      case PAN_BUTTON:
+        this.lastMousePanPosition = null;
+        break;
+      case RECTANGLE_BUTTON:
+        if (this.rectangleStartPosition === null) {
+          // We got a right-button release without ever seeing the mouse-down;
+          // just return.
+          return;
+        }
+        this.finishRectangleZoom(event);
+        break;
+      default:
+        break;
     }
   }
 
+  private finishRectangleZoom(event: MouseEvent) {
+    const currentPosition = this.mousePlotLocation(event);
+    this.setZoomCorners(this.rectangleStartPosition, currentPosition);
+    this.rectangleStartPosition = null;
+    this.zoomRectangle.setPoints(new Float32Array([]));
+  }
+
   handleMouseMove(event: MouseEvent) {
     const mouseLocation = this.mouseCanvasLocation(event);
     if (buttonPressed(event, PAN_BUTTON) &&
@@ -845,6 +903,36 @@
           addVec(this.drawer.getZoom().offset, mouseDiff));
       this.lastMousePanPosition = mouseLocation;
     }
+    if (this.rectangleStartPosition !== null) {
+      if (buttonPressed(event, RECTANGLE_BUTTON)) {
+        // p0 and p1 are the two corners of the rectangle to draw.
+        const p0 = [...this.rectangleStartPosition];
+        const p1 = [...this.mousePlotLocation(event)];
+        const minVisible = this.drawer.minVisiblePoint();
+        const maxVisible = this.drawer.maxVisiblePoint();
+        // Modify the rectangle corners to display correctly if we are limiting
+        // the zoom to the x/y axis.
+        const x_pressed = Plot.keysPressed['x'];
+        const y_pressed = Plot.keysPressed["y"];
+        if (x_pressed && !y_pressed) {
+          p0[1] = minVisible[1];
+          p1[1] = maxVisible[1];
+        } else if (!x_pressed && y_pressed) {
+          p0[0] = minVisible[0];
+          p1[0] = maxVisible[0];
+        }
+        this.zoomRectangle.setPoints(
+            new Float32Array([p0[0], p0[1]]
+                                 .concat([p0[0], p1[1]])
+                                 .concat([p1[0], p1[1]])
+                                 .concat([p1[0], p0[1]])
+                                 .concat([p0[0], p0[1]])));
+      } else {
+        this.finishRectangleZoom(event);
+      }
+    } else {
+      this.zoomRectangle.setPoints(new Float32Array([]));
+    }
     this.lastMousePosition = mouseLocation;
   }
 
@@ -936,12 +1024,20 @@
     }
   }
 
-  handleKeyUp(event: KeyboardEvent) {
+  static handleKeyUp(event: KeyboardEvent) {
     Plot.keysPressed[event.key] = false;
   }
 
-  handleKeyDown(event: KeyboardEvent) {
+  static handleKeyDown(event: KeyboardEvent) {
     Plot.keysPressed[event.key] = true;
+    for (const plot of this.allPlots) {
+      if (Plot.keysPressed['Escape']) {
+        // Cancel zoom/pan operations on escape.
+        plot.lastMousePanPosition = null;
+        plot.rectangleStartPosition = null;
+        plot.zoomRectangle.setPoints(new Float32Array([]));
+      }
+    }
   }
 
   draw() {