Better controls on rects view.

Rect labels are clickable. Pan added via right click and drag, zoom added via scroll.

Bug: b/245914589
Test: npm run test:all
Change-Id: I49ffd26b1c735390960e6b523acf3070951c903d
diff --git a/tools/winscope-ng/src/viewers/components/rects/canvas_graphics.ts b/tools/winscope-ng/src/viewers/components/rects/canvas_graphics.ts
index f61fdb9..ab4bd54 100644
--- a/tools/winscope-ng/src/viewers/components/rects/canvas_graphics.ts
+++ b/tools/winscope-ng/src/viewers/components/rects/canvas_graphics.ts
@@ -16,60 +16,65 @@
 import { Rectangle } from "viewers/common/rectangle";
 import * as THREE from "three";
 import { CSS2DRenderer, CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
+import { ViewerEvents } from "viewers/common/viewer_events";
 
 export class CanvasGraphics {
   constructor() {
     //set up camera
-    const left = -this.cameraHalfWidth,
-      right = this.cameraHalfWidth,
-      top = this.cameraHalfHeight,
-      bottom = -this.cameraHalfHeight,
-      near = 0.001,
-      far = 100;
+    const left = -this.CAMERA_HALF_WIDTH,
+          right = this.CAMERA_HALF_WIDTH,
+          top = this.CAMERA_HALF_HEIGHT,
+          bottom = -this.CAMERA_HALF_HEIGHT,
+          near = 0.001,
+          far = 100;
     this.camera = new THREE.OrthographicCamera(
-      left,right,top,bottom,near,far
+      left, right, top, bottom, near, far
     );
+    this.resetCamera();
   }
 
-  public initialise(canvas: HTMLCanvasElement, canvasContainer: Element) {
+  public initialiseCanvas(canvas: HTMLCanvasElement, canvasContainer: Element) {
     // initialise canvas
     this.canvas = canvas;
     this.canvasContainer = canvasContainer;
+    this.enableOrbitControls();
   }
 
   public refreshCanvas() {
-    //set canvas size
-    this.canvas!.style.width = "100%";
-    this.canvas!.style.height = "40rem";
+    if (!this.canvas) {
+      return;
+    }
 
-    this.camera.position.set(this.xCameraPos, Math.abs(this.xCameraPos), 6);
-    this.camera.lookAt(0, 0, 0);
-    this.camera.zoom = this.camZoom;
-    this.camera.updateProjectionMatrix();
+    //set canvas size
+    this.canvas.style.width = "100%";
+    this.canvas.style.height = "40rem";
+
+    this.orbit?.reset();
 
     // scene
-    const scene = new THREE.Scene();
+    this.scene = new THREE.Scene();
 
     // renderers
-    const renderer = new THREE.WebGLRenderer({
+    this.renderer = new THREE.WebGLRenderer({
       antialias: true,
       canvas: this.canvas,
       alpha: true
     });
-    let labelRenderer: CSS2DRenderer;
+
     if (this.canvasContainer && this.canvasContainer.querySelector(".labels-canvas")) {
-      labelRenderer = new CSS2DRenderer({
+      this.labelRenderer = new CSS2DRenderer({
         element: this.canvasContainer.querySelector(".labels-canvas")! as HTMLElement
       });
     } else {
-      labelRenderer = new CSS2DRenderer();
-      labelRenderer.domElement.style.position = "absolute";
-      labelRenderer.domElement.style.top = "0px";
-      labelRenderer.domElement.style.width = "100%";
-      labelRenderer.domElement.style.height = "40rem";
-      labelRenderer.domElement.className = "labels-canvas";
-      labelRenderer.domElement.style.pointerEvents = "none";
-      this.canvasContainer?.appendChild(labelRenderer.domElement);
+      this.labelRenderer = new CSS2DRenderer();
+      this.labelRenderer.domElement.style.position = "absolute";
+      this.labelRenderer.domElement.style.top = "0px";
+      this.labelRenderer.domElement.style.width = "100%";
+      this.labelRenderer.domElement.style.height = "40rem";
+      this.labelRenderer.domElement.className = "labels-canvas";
+      this.labelRenderer.domElement.style.pointerEvents = "none";
+      this.canvasContainer?.appendChild(this.labelRenderer.domElement);
     }
 
     // set various factors for shading and shifting
@@ -78,8 +83,8 @@
     const numberOfVisibleRects = this.rects.filter(rect => rect.isVisible).length;
     const numberOfNonVisibleRects = this.rects.filter(rect => !rect.isVisible).length;
 
-    const zShift = numberOfRects*this.layerSeparation;
-    let xShift = 0, yShift = 3.25, labelYShift = 0;
+    const zShift = numberOfRects * this.layerSeparation;
+    let xShift = 0, yShift = 3.5, labelYShift = 0;
 
     if (this.isLandscape) {
       xShift = 1;
@@ -90,7 +95,7 @@
     const lowestY = Math.min(...this.rects.map(rect => {
       const y = rect.topLeft.y - rect.height + this.lowestYShift;
       if (this.isLandscape) {
-        if (y<0) {
+        if (y < 0) {
           return 0;
         } else if (y > 2) {
           return 2;
@@ -108,24 +113,41 @@
       numberOfNonVisibleRects,
       nonVisibleDarkFactor,
       numberOfRects,
-      scene,
       xShift,
       yShift,
       zShift,
       lowestY
     );
 
-    // const axesHelper = new THREE.AxesHelper(1);
-    // const gridHelper = new THREE.GridHelper(5);
-    // scene.add(axesHelper, gridHelper)
+    this.renderer.setSize(this.canvas!.clientWidth, this.canvas!.clientHeight);
+    this.renderer.setPixelRatio(window.devicePixelRatio);
+    this.renderer.compile(this.scene, this.camera);
+    this.renderer.render(this.scene, this.camera);
 
-    renderer.setSize(this.canvas!.clientWidth, this.canvas!.clientHeight);
-    renderer.setPixelRatio(window.devicePixelRatio);
-    renderer.compile(scene, this.camera);
-    renderer.render(scene, this.camera);
+    this.labelRenderer.setSize(this.canvas!.clientWidth, this.canvas!.clientHeight);
+    this.labelRenderer.render(this.scene, this.camera);
+  }
 
-    labelRenderer.setSize(this.canvas!.clientWidth, this.canvas!.clientHeight);
-    labelRenderer.render(scene, this.camera);
+  public enableOrbitControls() {
+    this.orbit = new OrbitControls(this.camera, this.canvas);
+    this.orbit.enablePan = true;
+    this.orbit.enableDamping = true;
+    this.orbit.enableZoom = true;
+    this.orbit.maxZoom = this.MAX_ZOOM;
+    this.orbit.minZoom = this.MIN_ZOOM;
+    this.orbit.panSpeed = this.PAN_SPEED;
+    this.orbit.mouseButtons = { RIGHT: THREE.MOUSE.PAN };
+    this.orbit.addEventListener("change", () => {
+      this.fontSize = this.camera.zoom * this.INIT_FONT_SIZE;
+      this.updateLabelsFontSize();
+      if (this.scene && this.renderer && this.labelRenderer) {
+        this.clearLabelElements();
+        this.renderer.compile(this.scene, this.camera);
+        this.renderer.render(this.scene, this.camera);
+        this.labelRenderer.render(this.scene, this.camera);
+        this.orbit?.saveState();
+      }
+    });
   }
 
   public getCamera() {
@@ -145,7 +167,7 @@
   }
 
   public getXCameraPos() {
-    return this.xCameraPos;
+    return this.camera.position.x;
   }
 
   public getShowVirtualDisplays() {
@@ -157,10 +179,11 @@
   }
 
   public updateRotation(userInput: number) {
-    this.xCameraPos = userInput;
-    this.camZoom = userInput/4 * 0.2 + 0.9;
-    this.labelShift = userInput/4 * this.maxLabelShift;
-    this.lowestYShift = Math.abs(userInput)/4 + 2;
+    this.camera.position.x = userInput;
+    this.camera.position.y = Math.abs(userInput);
+    this.labelShift = userInput / 4 * this.MAX_LABEL_SHIFT;
+    this.lowestYShift = Math.abs(userInput) / 4 + 2;
+    this.updateCameraAndControls();
   }
 
   public updateHighlightedItems(newItems: Array<string>) {
@@ -183,24 +206,54 @@
     this.showVirtualDisplays = show;
   }
 
-  public updateZoom(isZoomIn: boolean) {
-    if (isZoomIn && this.camZoom < 2) {
-      this.labelXFactor -= 0.001;
-      this.camZoom += this.camZoomFactor * 1.5;
-    } else if (!isZoomIn && this.camZoom > 0.5) {
-      this.labelXFactor += 0.001;
-      this.camZoom -= this.camZoomFactor * 1.5;
+  public resetCamera() {
+    this.camera.lookAt(
+      this.INIT_TARGET.x,
+      this.INIT_TARGET.y,
+      this.INIT_TARGET.z
+    );
+    this.camera.position.set(
+      this.INIT_CAMERA_POS.x,
+      this.INIT_CAMERA_POS.y,
+      this.INIT_CAMERA_POS.z
+    );
+    this.camera.zoom = this.INIT_ZOOM;
+    this.fontSize = this.INIT_FONT_SIZE;
+    this.labelShift = this.MAX_LABEL_SHIFT;
+    this.lowestYShift = this.INIT_LOWEST_Y_SHIFT;
+    this.layerSeparation = this.INIT_LAYER_SEPARATION;
+    this.camera.updateProjectionMatrix();
+    if (this.canvas) {
+      this.enableOrbitControls();
     }
+    this.refreshCanvas();
+  }
+
+  public updateZoom(isZoomIn: boolean) {
+    if (isZoomIn && this.camera.zoom < this.MAX_ZOOM) {
+      this.camera.zoom += this.CAM_ZOOM_FACTOR;
+      if (this.camera.zoom > this.MAX_ZOOM) this.camera.zoom = this.MAX_ZOOM;
+    } else if (!isZoomIn && this.camera.zoom > this.MIN_ZOOM) {
+      this.camera.zoom -= this.CAM_ZOOM_FACTOR;
+      if (this.camera.zoom < this.MIN_ZOOM) this.camera.zoom = this.MIN_ZOOM;
+    }
+    this.fontSize = this.camera.zoom * this.INIT_FONT_SIZE;
+    this.updateCameraAndControls();
+  }
+
+  private updateCameraAndControls() {
+    this.camera.updateProjectionMatrix();
+    this.orbit?.update();
+    this.orbit?.saveState();
   }
 
   private drawScene(
     rectCounter: number,
     numberOfVisibleRects: number,
-    visibleDarkFactor:number,
+    visibleDarkFactor: number,
     numberOfNonVisibleRects: number,
     nonVisibleDarkFactor: number,
     numberOfRects: number,
-    scene: THREE.Scene,
     xShift: number,
     yShift: number,
     zShift: number,
@@ -211,7 +264,7 @@
     this.rects.forEach(rect => {
       const mustNotDrawInVisibleView = this.visibleView && !rect.isVisible;
       const mustNotDrawInXrayViewWithoutVirtualDisplays =
-            !this.visibleView && !this.showVirtualDisplays && rect.isVirtual;
+        !this.visibleView && !this.showVirtualDisplays && rect.isVirtual;
       if (mustNotDrawInVisibleView || mustNotDrawInXrayViewWithoutVirtualDisplays) {
         rectCounter++;
         return;
@@ -232,13 +285,13 @@
       //set plane geometry and material
       const geometry = new THREE.PlaneGeometry(rect.width, rect.height);
       const planeRect = this.setPlaneMaterial(rect, geometry, planeColor, xShift, yShift, zShift);
-      scene.add(planeRect);
+      this.scene?.add(planeRect);
       zShift -= this.layerSeparation;
 
       // bolder edges of each plane if in x-ray view
       if (!this.visibleView) {
         const edgeSegments = this.setEdgeMaterial(planeRect, geometry);
-        scene.add(edgeSegments);
+        this.scene?.add(edgeSegments);
       }
 
       // only some rects are clickable
@@ -247,10 +300,10 @@
       // labelling elements
       if (rect.label.length > 0) {
         const circle = this.setCircleMaterial(planeRect, rect);
-        scene.add(circle);
+        this.scene?.add(circle);
         const [line, rectLabel] = this.createLabel(rect, circle, lowestY, rectCounter);
-        scene.add(line);
-        scene.add(rectLabel);
+        this.scene?.add(line);
+        this.scene?.add(rectLabel);
       }
 
       rectCounter++;
@@ -272,9 +325,9 @@
         opacity: this.visibleView ? 1 : 0.75,
         transparent: true,
       }));
-    planeRect.position.y = rect.topLeft.y - rect.height/2 + yShift;
-    planeRect.position.x = rect.topLeft.x + rect.width/2 - xShift;
-    planeRect.position.z =  zShift;
+    planeRect.position.y = rect.topLeft.y - rect.height / 2 + yShift;
+    planeRect.position.x = rect.topLeft.x + rect.width / 2 - xShift;
+    planeRect.position.z = zShift;
     planeRect.name = `${rect.id}`;
     return planeRect;
   }
@@ -282,7 +335,7 @@
   private setEdgeMaterial(planeRect: THREE.Mesh, geometry: THREE.PlaneGeometry) {
     const edgeColor = 0x000000;
     const edgeGeo = new THREE.EdgesGeometry(geometry);
-    const edgeMaterial = new THREE.LineBasicMaterial({color: edgeColor, linewidth: 1});
+    const edgeMaterial = new THREE.LineBasicMaterial({ color: edgeColor, linewidth: 1 });
     const edgeSegments = new THREE.LineSegments(
       edgeGeo, edgeMaterial
     );
@@ -295,7 +348,7 @@
     const circleMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 });
     const circle = new THREE.Mesh(labelCircle, circleMaterial);
     circle.position.set(
-      planeRect.position.x + rect.width/2 - 0.05,
+      planeRect.position.x + rect.width / 2 - 0.05,
       planeRect.position.y,
       planeRect.position.z + 0.05
     );
@@ -307,34 +360,35 @@
     [THREE.Line, CSS2DObject] {
     const labelText = this.shortenText(rect.label);
     const isGrey = !this.visibleView && !rect.isVisible;
-    let cornerPos, endPos;
-    const labelYSeparation = 0.3;
+    let endPos;
+    const labelYSeparation = 0.5;
     if (this.isLandscape) {
-      cornerPos = new THREE.Vector3(
-        circle.position.x, lowestY - 0.5 - rectCounter*labelYSeparation, circle.position.z
+      endPos = new THREE.Vector3(
+        circle.position.x, lowestY - 0.5 - rectCounter * labelYSeparation, circle.position.z
       );
     } else {
-      cornerPos = new THREE.Vector3(
-        circle.position.x, lowestY + 0.5 - rectCounter*labelYSeparation, circle.position.z
+      endPos = new THREE.Vector3(
+        circle.position.x, lowestY + 0.5 - rectCounter * labelYSeparation, circle.position.z
       );
     }
 
-    const linePoints = [circle.position, cornerPos];
-    if (this.isLandscape && cornerPos.x > 0 || !this.isLandscape) {
-      endPos = new THREE.Vector3(cornerPos.x - 0.75, cornerPos.y - 0.75*this.labelShift, cornerPos.z);
-    } else {
-      endPos = cornerPos;
-    }
-    linePoints.push(endPos);
+    const linePoints = [circle.position, endPos];
+
 
     //add rectangle label
     const rectLabelDiv: HTMLElement = document.createElement("div");
     rectLabelDiv.className = "rect-label";
     rectLabelDiv.textContent = labelText;
-    rectLabelDiv.style.fontSize = "10px";
+    rectLabelDiv.style.fontSize = `${this.fontSize}` + "px";
+    rectLabelDiv.style.marginTop = "5px";
     if (isGrey) {
       rectLabelDiv.style.color = "grey";
     }
+    rectLabelDiv.style.pointerEvents = "auto";
+    rectLabelDiv.style.cursor = "pointer";
+    rectLabelDiv.addEventListener(
+      "click", (event) => this.propagateUpdateHighlightedItems(event, rect.id)
+    );
     const rectLabel = new CSS2DObject(rectLabelDiv);
     rectLabel.name = rect.label;
 
@@ -350,28 +404,45 @@
 
     if (this.isLandscape && endPos.x < 0) {
       rectLabel.position.set(
-        endPos.x + 0.6, endPos.y - 0.15, endPos.z - 0.6
+        endPos.x + 0.6, endPos.y, endPos.z - 0.6
       );
     } else {
       rectLabel.position.set(
-        endPos.x - labelWidth * this.labelXFactor,
-        endPos.y - this.labelShift * labelWidth * this.labelXFactor,
+        endPos.x - labelWidth * this.LABEL_X_FACTOR,
+        endPos.y - this.labelShift * labelWidth * this.LABEL_X_FACTOR,
         endPos.z
       );
     }
 
     const lineGeo = new THREE.BufferGeometry().setFromPoints(linePoints);
-    const lineMaterial = new THREE.LineBasicMaterial({color: isGrey ? 0x808080 : 0x000000});
+    const lineMaterial = new THREE.LineBasicMaterial({ color: isGrey ? 0x808080 : 0x000000 });
     const line = new THREE.Line(lineGeo, lineMaterial);
 
     return [line, rectLabel];
   }
 
+  private propagateUpdateHighlightedItems(event: MouseEvent, newId: number) {
+    event.preventDefault();
+    const highlightedChangeEvent: CustomEvent = new CustomEvent(
+      ViewerEvents.HighlightedChange,
+      {
+        bubbles: true,
+        detail: { id: `${newId}` }
+      });
+    event.target?.dispatchEvent(highlightedChangeEvent);
+  }
+
+  private updateLabelsFontSize() {
+    document.querySelectorAll(".rect-label").forEach(
+      el => (el as HTMLElement).style.fontSize = `${this.fontSize}` + "px"
+    );
+  }
+
   private clearLabelElements() {
     document.querySelectorAll(".rect-label").forEach(el => el.remove());
   }
 
-  private colorMapping(scale: string, numberOfRects: number, darkFactor:number): THREE.Color {
+  private colorMapping(scale: string, numberOfRects: number, darkFactor: number): THREE.Color {
     if (scale === "highlighted") {
       return new THREE.Color(0xD2E3FC);
     } else if (scale === "grey") {
@@ -379,14 +450,14 @@
       //Separate RGB values between 0 and 1
       const lower = 120;
       const upper = 220;
-      const darkness = ((upper-lower)*(numberOfRects-darkFactor)/numberOfRects + lower)/255;
+      const darkness = ((upper - lower) * (numberOfRects - darkFactor) / numberOfRects + lower) / 255;
       return new THREE.Color(darkness, darkness, darkness);
     } else if (scale === "green") {
       // darkness of green rect depends on z order
       //Separate RGB values between 0 and 1
-      const red = ((200-45)*(numberOfRects-darkFactor)/numberOfRects + 45)/255;
-      const green = ((232-182)*(numberOfRects-darkFactor)/numberOfRects + 182)/255;
-      const blue = ((183-44)*(numberOfRects-darkFactor)/numberOfRects + 44)/255;
+      const red = ((200 - 45) * (numberOfRects - darkFactor) / numberOfRects + 45) / 255;
+      const green = ((232 - 182) * (numberOfRects - darkFactor) / numberOfRects + 182) / 255;
+      const blue = ((183 - 44) * (numberOfRects - darkFactor) / numberOfRects + 44) / 255;
       return new THREE.Color(red, green, blue);
     } else {
       return new THREE.Color(0, 0, 0);
@@ -401,21 +472,35 @@
   }
 
   // dynamic scaling and canvas variables
-  readonly cameraHalfWidth = 2.8;
-  readonly cameraHalfHeight = 3.2;
-  private readonly maxLabelShift = 0.305;
-  private labelXFactor = 0.009;
-  private lowestYShift = 3;
-  private camZoom = 1.1;
-  private camZoomFactor = 0.1;
-  private labelShift = this.maxLabelShift;
+  readonly CAMERA_HALF_WIDTH = 2.8;
+  readonly CAMERA_HALF_HEIGHT = 3.2;
+  private readonly MAX_LABEL_SHIFT = 0.305;
+  private readonly MAX_ZOOM = 2.5;
+  private readonly MIN_ZOOM = 0.5;
+  private readonly INIT_ZOOM = 1;
+  private readonly INIT_FONT_SIZE = 10;
+  private readonly INIT_CAMERA_POS = new THREE.Vector3(4, 4, 6);
+  private readonly INIT_TARGET = new THREE.Vector3(0, 0, 0);
+  private readonly INIT_LAYER_SEPARATION = 0.4;
+  private readonly INIT_LOWEST_Y_SHIFT = 3;
+  private readonly PAN_SPEED = 1;
+  private readonly LABEL_X_FACTOR = 0.009;
+  private readonly CAM_ZOOM_FACTOR = 0.15;
+
+  private fontSize = this.INIT_FONT_SIZE;
+  private labelShift = this.MAX_LABEL_SHIFT;
+  private lowestYShift = this.INIT_LOWEST_Y_SHIFT;
+  private layerSeparation = this.INIT_LAYER_SEPARATION;
+
   private visibleView = false;
   private isLandscape = false;
   private showVirtualDisplays = false;
-  private layerSeparation = 0.4;
-  private xCameraPos = 4;
   private highlightedItems: Array<string> = [];
   private camera: THREE.OrthographicCamera;
+  private scene?: THREE.Scene;
+  private renderer?: THREE.WebGLRenderer;
+  private labelRenderer?: CSS2DRenderer;
+  private orbit?: OrbitControls;
   private rects: Rectangle[] = [];
   private targetObjects: any[] = [];
   private canvas?: HTMLCanvasElement;
diff --git a/tools/winscope-ng/src/viewers/components/rects/rects.component.spec.ts b/tools/winscope-ng/src/viewers/components/rects/rects.component.spec.ts
index 7704948..96f3e59 100644
--- a/tools/winscope-ng/src/viewers/components/rects/rects.component.spec.ts
+++ b/tools/winscope-ng/src/viewers/components/rects/rects.component.spec.ts
@@ -101,9 +101,9 @@
         isClickable: false
       }
     ]);
-    spyOn(component.rectsComponent, "drawRects").and.callThrough();
+    spyOn(component.rectsComponent, "refreshCanvas").and.callThrough();
     fixture.detectChanges();
-    expect(component.rectsComponent.drawRects).toHaveBeenCalled();
+    expect(component.rectsComponent.refreshCanvas).toHaveBeenCalled();
   });
 
   @Component({
diff --git a/tools/winscope-ng/src/viewers/components/rects/rects.component.ts b/tools/winscope-ng/src/viewers/components/rects/rects.component.ts
index 2c2a5f8..0dcb760 100644
--- a/tools/winscope-ng/src/viewers/components/rects/rects.component.ts
+++ b/tools/winscope-ng/src/viewers/components/rects/rects.component.ts
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { Component, Input, OnChanges, OnDestroy, Inject, ElementRef, SimpleChanges, OnInit } from "@angular/core";
+import { Component, Input, OnChanges, OnDestroy, Inject, ElementRef, SimpleChanges, OnInit} from "@angular/core";
 import { RectsUtils } from "viewers/components/rects/rects_utils";
 import { Point, Rectangle, RectMatrix, RectTransform } from "viewers/common/rectangle";
 import { CanvasGraphics } from "viewers/components/rects/canvas_graphics";
@@ -40,17 +40,26 @@
             [checked]="showVirtualDisplays()"
             (change)="updateVirtualDisplays($event.checked!)"
           >Show virtual</mat-checkbox>
-          <div class="zoom-container control-item">
-            <button class="zoom-btn" (click)="updateZoom(true)">
+          <div class="right-btn-container control-item">
+            <button class="right-btn" (click)="updateZoom(true)">
               <mat-icon aria-hidden="true">
                 zoom_in
               </mat-icon>
             </button>
-            <button class="zoom-btn" (click)="updateZoom(false)">
+            <button class="right-btn" (click)="updateZoom(false)">
               <mat-icon aria-hidden="true">
                 zoom_out
               </mat-icon>
             </button>
+            <button
+              class="right-btn"
+              (click)="resetCamera()"
+              matTooltip="Restore camera settings"
+            >
+              <mat-icon aria-hidden="true">
+                restore
+              </mat-icon>
+            </button>
           </div>
         </div>
       </div>
@@ -58,7 +67,7 @@
         <div class="slider" [class.rotation]="true">
           <span class="slider-label">Rotation</span>
           <mat-slider
-            step="0.01"
+            step="0.001"
             min="0"
             max="4"
             aria-label="units"
@@ -82,7 +91,7 @@
     </mat-card-header>
     <mat-card-content class="rects-content">
       <div class="canvas-container">
-        <canvas class="rects-canvas" (click)="onRectClick($event)">
+        <canvas class="rects-canvas" (click)="onRectClick($event)" oncontextmenu="return false">
         </canvas>
       </div>
       <div class="tabs" *ngIf="displayIds.length > 1">
@@ -153,12 +162,12 @@
         padding-top: 0px;
         font-weight: bold;
       }
-      .zoom-container {
+      .right-btn-container {
         position: relative;
         vertical-align: middle;
         float: right;
       }
-      .zoom-btn {
+      .right-btn {
         position: relative;
         display: inline-flex;
         background: none;
@@ -192,26 +201,35 @@
   @Input() hasVirtualDisplays = false;
   @Input() displayIds: Array<number> = [];
   @Input() highlightedItems: Array<string> = [];
+  canvasInitialised = false;
+  rectsComponentInitialised = false;
 
   constructor(
     @Inject(ElementRef) private elementRef: ElementRef,
   ) {
     this.canvasGraphics = new CanvasGraphics();
-    this.currentDisplayId = this.displayIds[0] ?? 0; //default stack id is usually zero
+    this.currentDisplayId = this.displayIds[0] ?? 0; // default stack id is usually zero
   }
 
   ngOnInit() {
+    this.canvas = this.elementRef.nativeElement.querySelector("canvas")! as HTMLCanvasElement;
+    this.canvasContainer = this.elementRef.nativeElement.querySelector(".canvas-container")!;
+
     window.addEventListener("resize", () => this.refreshCanvas());
-    this.canvasContainer = this.elementRef.nativeElement.querySelector(".canvas-container");
-    this.resizeObserver = new ResizeObserver((entries) => {
-      if (entries[0].contentRect.height > 0) {
-        this.refreshCanvas();
-      }
-    });
-    this.resizeObserver.observe(this.canvasContainer!);
+    this.addContainerResizeListener();
+
+    this.currentDisplayId = this.displayIds[0];
+    this.canvasGraphics.updateHighlightedItems(this.highlightedItems);
+    if (this.rects.length > 0) {
+      this.formatAndDrawRects(true);
+    }
+    this.rectsComponentInitialised = true; // prevent ngOnChanges from being called before ngOnInit
   }
 
   ngOnChanges(changes: SimpleChanges) {
+    if (!this.rectsComponentInitialised) {
+      return; // ngOnInit not yet called
+    }
     if (changes["displayIds"]) {
       if (!this.displayIds.includes(this.currentDisplayId)) {
         this.currentDisplayId = this.displayIds[0];
@@ -253,6 +271,11 @@
     this.refreshCanvas();
   }
 
+  public resetCamera() {
+    this.canvasGraphics.resetCamera();
+    this.refreshCanvas();
+  }
+
   public updateZoom(zoom: boolean) {
     this.canvasGraphics.updateZoom(zoom);
     this.refreshCanvas();
@@ -276,22 +299,26 @@
     this.refreshCanvas();
   }
 
-  public onRectClick(event:MouseEvent) {
+  public onRectClick(event: MouseEvent) {
     this.setNormalisedMousePos(event);
     const raycaster = new THREE.Raycaster();
     raycaster.setFromCamera(this.mouse, this.canvasGraphics.getCamera());
     // create an array containing all objects in the scene with which the ray intersects
     const intersects = raycaster.intersectObjects(this.canvasGraphics.getTargetObjects());
     // if there is one (or more) intersections
-    if (intersects.length > 0){
+    if (intersects.length > 0) {
       const id = intersects[0].object.name;
       this.updateHighlightedItems(id);
     }
   }
 
   public drawRects() {
-    const canvas = this.elementRef.nativeElement.querySelector(".rects-canvas") as HTMLCanvasElement;
-    this.canvasGraphics.initialise(canvas, this.canvasContainer);
+    if (!this.canvasContainer || !this.canvas) {
+      return;
+    } else if (!this.canvasInitialised) {
+      this.canvasGraphics.initialiseCanvas(this.canvas, this.canvasContainer);
+      this.canvasInitialised = true;
+    }
     this.refreshCanvas();
   }
 
@@ -310,7 +337,7 @@
     this.drawRects();
   }
 
-  private setNormalisedMousePos(event:MouseEvent) {
+  private setNormalisedMousePos(event: MouseEvent) {
     event.preventDefault();
     const canvas = (event.target as Element);
     const canvasOffset = canvas.getBoundingClientRect();
@@ -345,7 +372,7 @@
     });
   }
 
-  private refreshCanvas() {
+  public refreshCanvas() {
     this.updateVariablesBeforeRefresh();
     this.canvasGraphics.refreshCanvas();
   }
@@ -391,9 +418,9 @@
   private s(sourceCoordinates: Point): Point {
     let scale;
     if (this.boundsWidth < this.boundsHeight) {
-      scale = this.canvasGraphics.cameraHalfHeight*2 * 0.6 / this.boundsHeight;
+      scale = this.canvasGraphics.CAMERA_HALF_HEIGHT * 2 * 0.6 / this.boundsHeight;
     } else {
-      scale = this.canvasGraphics.cameraHalfWidth*2 * 0.6 / this.boundsWidth;
+      scale = this.canvasGraphics.CAMERA_HALF_WIDTH * 2 * 0.6 / this.boundsWidth;
     }
     return {
       x: sourceCoordinates.x * scale,
@@ -413,6 +440,15 @@
     }
   }
 
+  private addContainerResizeListener() {
+    this.resizeObserver = new ResizeObserver((entries) => {
+      if (entries[0].contentRect.height > 0 && this.canvasInitialised) {
+        this.refreshCanvas();
+      }
+    });
+    this.resizeObserver.observe(this.canvasContainer!);
+  }
+
   private canvasGraphics: CanvasGraphics;
   private boundsWidth = 0;
   private boundsHeight = 0;
@@ -421,4 +457,5 @@
   private currentDisplayId: number;
   private resizeObserver!: ResizeObserver;
   private canvasContainer!: Element;
+  private canvas!: HTMLCanvasElement;
 }