chromium/chrome/browser/resources/chromeos/healthd_internals/line_chart/line_chart.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import './menu.js';
import './scrollbar.js';

import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {DEFAULT_TIME_SCALE, DRAG_RATE, MAX_TIME_SCALE, MIN_TIME_SCALE, MOUSE_WHEEL_SCROLL_RATE, MOUSE_WHEEL_UNITS, TOUCH_ZOOM_UNITS, ZOOM_RATE} from './configs.js';
import {getTemplate} from './line_chart.html.js';
import type {HealthdInternalsLineChartMenuElement} from './menu.js';
import type {HealthdInternalsLineChartScrollbarElement} from './scrollbar.js';
import {CanvasDrawer} from './utils/canvas_drawer.js';
import {DataSeries} from './utils/data_series.js';

/**
 * Return the distance of two touch points.
 */
function getTouchsDistance(touchA: Touch, touchB: Touch): number {
  const diffX: number = touchA.clientX - touchB.clientX;
  const diffY: number = touchA.clientY - touchB.clientY;
  return Math.sqrt(diffX * diffX + diffY * diffY);
}

export interface HealthdInternalsLineChartElement {
  $: {
    chartRoot: HTMLElement,
    mainCanvas: HTMLCanvasElement,
    chartMenu: HealthdInternalsLineChartMenuElement,
    chartScrollbar: HealthdInternalsLineChartScrollbarElement,
  };
}

/**
 * Create a line chart canvas, including a left button menu and a bottom
 * scrollbar. This element will enroll the events of the line chart, handle the
 * scroll, touch and resize events, and render canvas by `canvasDrawer`.
 */
export class HealthdInternalsLineChartElement extends PolymerElement {
  static get is() {
    return 'healthd-internals-line-chart';
  }

  static get template() {
    return getTemplate();
  }

  override connectedCallback() {
    super.connectedCallback();
    this.startTime = Date.now();
    this.endTime = this.startTime + 1;

    this.initCanvasEventHandlers();

    const resizeObserver = new ResizeObserver(() => {
      this.resizeChart();
      this.updateChart();
    });
    resizeObserver.observe(this.$.chartRoot);

    window.addEventListener('bar-scroll', () => {
      this.updateChart();
    });

    window.addEventListener('menu-buttons-updated', () => {
      this.updateChart();
    });
  }

  // The start time of the time line chart. (Unix time)
  private startTime: number;
  // The end time of the time line chart. (Unix time)
  private endTime: number;

  // Horizontal scale of line chart. Number of milliseconds between two pixels.
  private timeScale: number = DEFAULT_TIME_SCALE;

  // The helper class to draw the canvas.
  private canvasDrawer: CanvasDrawer|null = null;

  // Used to avoid updating the graph multiple times in a single operation.
  private chartUpdateTimer: number = 0;

  // Status of dragging and touching events for scrolling and zooming.
  private isDragging: boolean = false;
  private dragX: number = 0;
  private isTouching: boolean = false;
  private touchX: number = 0;
  private touchZoomBase: number = 0;

  // Whether the line chart is visible. If not, we don't need to render canvas.
  private isVisible: boolean = false;

  // Initialize the `canvasDrawer`.
  initCanvasDrawer(units: string[], unitBase: number) {
    this.canvasDrawer = new CanvasDrawer(units, unitBase);
    this.updateChart();
  }

  // Add a data series to the line chart. Call `initCanvasDrawer()` before
  // calling this function.
  addDataSeries(dataSeries: DataSeries) {
    if (this.canvasDrawer === null) {
      console.warn(
          'This `canvasDrawer` has not been initilaized yet. Call ',
          '`initCanvasDrawer()` before calling this function.');
      return;
    }
    this.canvasDrawer.addDataSeries(dataSeries);
    this.$.chartMenu.addDataSeries(dataSeries);
    this.resizeChart();
    this.updateChart();
  }

  // Update the end time of the line chart. Also update the line chart and
  // scrollbar to display latest data.
  updateEndTime(endTime: number) {
    if (this.canvasDrawer === null) {
      console.warn(
          'This `canvasDrawer` has not been initilaized yet. Call ',
          '`initCanvasDrawer()` before calling this function.');
      return;
    }
    this.endTime = endTime;
    this.updateScrollBar();
    this.updateChart();
  }

  // Update the start time of the line chart when removing data. Also update the
  // line chart and scrollbar to display latest data.
  updateStartTime(startTime: number) {
    this.startTime = Math.max(this.startTime, startTime);
    this.updateScrollBar();
    this.updateChart();
  }

  // Update the visibility of line chart. We don't need to render the chart when
  // the chart is not visible.
  updateVisibility(isVisible: boolean) {
    this.isVisible = isVisible;
    if (isVisible) {
      this.updateScrollBar();
      this.updateChart();
    }
  }

  // Overwrite the maximum value of the chart.
  setChartMaxValue(maxValue: number|null) {
    if (this.canvasDrawer === null) {
      console.warn(
          'This `canvasDrawer` has not been initilaized yet. Call ',
          '`initCanvasDrawer()` before calling this function.');
      return;
    }
    this.canvasDrawer.setFixedMaxValue(maxValue);
  }

  // Handle the wheeling, mouse dragging and touching events.
  private initCanvasEventHandlers() {
    const canvas: HTMLCanvasElement = this.$.mainCanvas;
    canvas.addEventListener('wheel', (e: Event) => this.onWheel(e));
    canvas.addEventListener('mousedown', (e: Event) => this.onMouseDown(e));
    canvas.addEventListener('mousemove', (e: Event) => this.onMouseMove(e));
    canvas.addEventListener('mouseup', (e: Event) => this.onMouseUpOrOut(e));
    canvas.addEventListener('mouseout', (e: Event) => this.onMouseUpOrOut(e));
    canvas.addEventListener('touchstart', (e: Event) => this.onTouchStart(e));
    canvas.addEventListener('touchmove', (e: Event) => this.onTouchMove(e));
    canvas.addEventListener('touchend', (e: Event) => this.onTouchEnd(e));
    canvas.addEventListener('touchcancel', (e: Event) => this.onTouchCancel(e));
  }

  // Mouse and touchpad scroll event. Horizontal scroll for chart scrolling,
  // vertical scroll for chart zooming.
  private onWheel(event: Event) {
    event.preventDefault();
    const wheelEvent: WheelEvent = event as WheelEvent;
    const wheelX: number = wheelEvent.deltaX / MOUSE_WHEEL_UNITS;
    const wheelY: number = -wheelEvent.deltaY / MOUSE_WHEEL_UNITS;
    this.scrollChart(MOUSE_WHEEL_SCROLL_RATE * wheelX);
    this.zoomChart(Math.pow(ZOOM_RATE, -wheelY));
  }

  // The following three functions handle mouse dragging event.
  private onMouseDown(event: Event) {
    event.preventDefault();
    this.isDragging = true;
    this.dragX = (event as MouseEvent).clientX;
  }

  private onMouseMove(event: Event) {
    event.preventDefault();
    if (!this.isDragging) {
      return;
    }
    const mouseEvent: MouseEvent = event as MouseEvent;
    const dragDeltaX = mouseEvent.clientX - this.dragX;
    this.scrollChart(DRAG_RATE * dragDeltaX);
    this.dragX = mouseEvent.clientX;
  }

  private onMouseUpOrOut(event: Event) {
    event.preventDefault();
    this.isDragging = false;
  }

  // The following four functions handle touch events. One finger for scrolling,
  // two finger for zooming.
  private onTouchStart(event: Event) {
    event.preventDefault();
    this.isTouching = true;
    const touches = (event as TouchEvent).targetTouches;
    if (touches.length === 1) {
      this.touchX = touches[0].clientX;
    } else if (touches.length === 2) {
      this.touchZoomBase = getTouchsDistance(touches[0], touches[1]);
    }
  }

  private onTouchMove(event: Event) {
    event.preventDefault();
    if (!this.isTouching) {
      return;
    }
    const touches: TouchList = (event as TouchEvent).targetTouches;
    if (touches.length === 1) {
      const dragDeltaX: number = this.touchX - touches[0].clientX;
      this.scrollChart(DRAG_RATE * dragDeltaX);
      this.touchX = touches[0].clientX;
    } else if (touches.length === 2) {
      const newDistance: number = getTouchsDistance(touches[0], touches[1]);
      const zoomDelta: number =
          (this.touchZoomBase - newDistance) / TOUCH_ZOOM_UNITS;
      this.zoomChart(Math.pow(ZOOM_RATE, zoomDelta));
      this.touchZoomBase = newDistance;
    }
  }

  private onTouchEnd(event: Event) {
    event.preventDefault();
    this.isTouching = false;
  }

  private onTouchCancel(event: Event) {
    event.preventDefault();
    this.isTouching = false;
  }

  // Zoom the line chart by setting the `timeScale` to `rate` times.
  private zoomChart(rate: number) {
    const oldScale: number = this.timeScale;
    const newScale: number = this.timeScale * rate;
    this.timeScale =
        Math.max(MIN_TIME_SCALE, Math.min(newScale, MAX_TIME_SCALE));

    if (this.timeScale === oldScale) {
      return;
    }

    if (this.$.chartScrollbar.isScrolledToRightEdge()) {
      this.updateScrollBar();
      this.$.chartScrollbar.scrollToRightEdge();
      this.updateChart();
      return;
    }

    // To try to make the chart keep right, make the right edge of the chart
    // stop at the same position.
    const oldPosition: number = this.$.chartScrollbar.getPosition();
    const canvasWidth: number = this.$.mainCanvas.width;
    const visibleEndTime: number = oldScale * (oldPosition + canvasWidth);
    const newPosition: number =
        Math.round(visibleEndTime / this.timeScale) - canvasWidth;

    this.updateScrollBar();
    this.$.chartScrollbar.setPosition(newPosition);
    this.updateChart();
  }

  // Scroll the line chart by moving forward `delta` pixels.
  private scrollChart(delta: number) {
    const oldPosition: number = this.$.chartScrollbar.getPosition();
    const newPosition: number = oldPosition + Math.round(delta);

    this.$.chartScrollbar.setPosition(newPosition);
    if (this.$.chartScrollbar.getPosition() === oldPosition) {
      return;
    }
    this.updateChart();
  }

  private resizeChart() {
    const width = this.getChartVisibleWidth();
    const height = this.getChartVisibleHeight();
    if (this.$.mainCanvas.width === width &&
        this.$.mainCanvas.height === height) {
      return;
    }

    this.$.mainCanvas.width = width;
    this.$.mainCanvas.height = height;
    this.$.chartScrollbar.resize(width);
    this.updateScrollBar();
  }

  // Update the scrollbar to the current line chart status after zooming,
  // scrolling... etc.
  private updateScrollBar() {
    if (!this.isVisible) {
      return;
    }
    const scrollRange: number =
        Math.max(this.getChartWidth() - this.getChartVisibleWidth(), 0);
    const isScrolledToRightEdge: boolean =
        this.$.chartScrollbar.isScrolledToRightEdge();
    this.$.chartScrollbar.setScrollableRange(scrollRange);
    if (isScrolledToRightEdge && !this.isDragging) {
      this.$.chartScrollbar.scrollToRightEdge();
    }
  }

  // Get the whole line chart width, in pixel.
  private getChartWidth(): number {
    const timeRange: number = this.endTime - this.startTime;
    return Math.floor(timeRange / this.timeScale);
  }

  // Get the visible chart width.
  private getChartVisibleWidth(): number {
    return this.$.chartRoot.offsetWidth - this.$.chartMenu.getWidth();
  }

  // Get the visible chart height.
  private getChartVisibleHeight(): number {
    return this.$.chartRoot.offsetHeight - this.$.chartScrollbar.getHeight();
  }

  // Render the line chart. Note that to avoid calling render function
  // multiple times in a single operation, this function will set a timeout
  // rather than calling render function directly.
  private updateChart() {
    clearTimeout(this.chartUpdateTimer);
    const context: CanvasRenderingContext2D|null =
        this.$.mainCanvas.getContext('2d');
    if (context === null || this.canvasDrawer === null ||
        !this.canvasDrawer.shouldRender() || !this.isVisible) {
      return;
    }
    this.chartUpdateTimer = setTimeout(() => this.renderCanvas(context));
  }

  // Render the chart content by `canvasDrawer`.
  private renderCanvas(context: CanvasRenderingContext2D) {
    // To reduce CPU usage, the chart do not draw points at every pixels.
    // We need to know the offset of data from `scrollbarPosition`.
    let scrollbarPosition: number = this.$.chartScrollbar.getPosition();
    if (this.$.chartScrollbar.getScrollableRange() === 0) {
      // If the chart width less than the visible width, make the chart align
      // right by setting the negative position.
      scrollbarPosition = this.getChartWidth() - this.$.mainCanvas.width;
    }

    if (this.canvasDrawer === null) {
      return;
    }

    const visibleStartTime: number =
        this.startTime + scrollbarPosition * this.timeScale;
    const visibleEndTime: number =
        visibleStartTime + this.getChartVisibleWidth() * this.timeScale;
    this.canvasDrawer.renderCanvas(
        context, this.$.mainCanvas.width, this.$.mainCanvas.height,
        visibleStartTime, visibleEndTime, this.timeScale);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'healthd-internals-line-chart': HealthdInternalsLineChartElement;
  }
}

customElements.define(
    HealthdInternalsLineChartElement.is, HealthdInternalsLineChartElement);