chromium/chrome/browser/resources/chromeos/sys_internals/line_chart/line_chart.js

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

import {createElementWithClassName} from 'chrome://resources/ash/common/util.js';

import {BACKGROUND_COLOR, CHART_MARGIN, DEFAULT_SCALE, DRAG_RATE, GRID_COLOR, MAX_SCALE, MIN_LABEL_VERTICAL_SPACING, MIN_SCALE, MIN_TIME_LABEL_HORIZONTAL_SPACING, MOUSE_WHEEL_SCROLL_RATE, MOUSE_WHEEL_UNITS, SAMPLE_RATE, TEXT_COLOR, TIME_STEP_UNITS, TOUCH_ZOOM_UNITS, ZOOM_RATE} from './constants.js';
import {DataSeries} from './data_series.js';
import {Menu} from './menu.js';
import {Scrollbar} from './scrollbar.js';
import {SubChart} from './sub_chart.js';
import {UnitLabel} from './unit_label.js';

/**
 * Create a canvas line chart. The object will enroll the events of the line
 * chart, handle the scroll and the touch events, render the time label, create
 * and control other object. See README for usage.
 * @const
 */
export class LineChart {
  constructor() {
    /** @type {Element} */
    this.rootDiv_ = null;

    /**
     * The start time of the time line chart. (Unix time)
     * @const {number}
     */
    this.startTime_ = Date.now();
    /**
     * The end time of the time line chart. (Unix time)
     * @type {number}
     */
    this.endTime_ = this.startTime_ + 1;

    /**
     * The scale of the line chart. Milliseconds per pixel.
     * @type {number}
     */
    this.scale_ = DEFAULT_SCALE;

    /**
     * |subChart| is the chart that all data series in it shares the same unit
     * label. There are two |SubChart| in |LineChart|, one's label align left,
     * another's align right. See |SubChart|.
     * @type {Array<SubChart>}
     */
    this.subCharts_ = [null, null];

    /**
     * Use a timer to avoid updating the graph multiple times in a single
     * operation.
     * @type {number}
     */
    this.chartUpdateTimer_ = 0;

    /* Dragging events status and touching events status. */
    /** @type {boolean} */
    this.isDragging_ = false;

    /** @type {number} */
    this.dragX_ = 0;

    /** @type {boolean} */
    this.isTouching_ = false;

    /** @type {number} */
    this.touchX_ = 0;

    /** @type {number} */
    this.touchZoomBase_ = 0;

    /**
     * The menu to control the visibility of data series. See |Menu|.
     * @const {Menu}
     */
    this.menu_ = new Menu(this.onMenuUpdate_.bind(this));

    /** @const {Element} */
    this.canvas_ = createElementWithClassName('canvas', 'line-chart-canvas');

    /** @const {CSSStyleDeclaration} */
    this.canvasStyle_ = window.getComputedStyle(this.canvas_);

    /**
     * A dummy scrollbar to scroll the line chart and to show the current
     * visible position of the line chair.
     * @const {Scrollbar}
     */
    this.scrollbar_ = new Scrollbar(this.update.bind(this));
  }

  /**
   * Attach the root div of
   * @param {Element} rootDiv
   */
  attachRootDiv(rootDiv) {
    if (this.rootDiv_ != null) {
      return;
    }

    this.rootDiv_ = rootDiv;

    const /** Element */ menuDiv = this.menu_.getRootDiv();
    this.rootDiv_.appendChild(menuDiv);

    const /** Element */ chartOuterDiv =
        createElementWithClassName('div', 'line-chart-canvas-outer');
    this.initCanvas_();
    chartOuterDiv.appendChild(this.canvas_);
    const /** Element */ scrollBarDiv = this.scrollbar_.getRootDiv();
    chartOuterDiv.appendChild(scrollBarDiv);
    this.rootDiv_.appendChild(chartOuterDiv);

    window.addEventListener('resize', this.onResize_.bind(this));
    /* Initialize the graph size. */
    this.resize_();
  }

  initCanvas_() {
    /**
     * Passive event will disable |preventDefault()|, set passive to false to
     * make sure this feature is disabled. The default value of passive is true
     * on some Chromebooks. see https://crbug.com/761698.
     * @param {Element} element
     * @param {string} name
     * @param {function(Event): undefined} handler
     */
    const enrollNonPassiveEvent = function(element, name, handler) {
      element.addEventListener(name, handler, {passive: false});
    };
    enrollNonPassiveEvent(this.canvas_, 'wheel', this.onWheel_.bind(this));
    enrollNonPassiveEvent(
        this.canvas_, 'mousedown', this.onMouseDown_.bind(this));
    enrollNonPassiveEvent(
        this.canvas_, 'mousemove', this.onMouseMove_.bind(this));
    enrollNonPassiveEvent(
        this.canvas_, 'mouseup', this.onMouseUpOrOut_.bind(this));
    enrollNonPassiveEvent(
        this.canvas_, 'mouseout', this.onMouseUpOrOut_.bind(this));
    enrollNonPassiveEvent(
        this.canvas_, 'touchstart', this.onTouchStart_.bind(this));
    enrollNonPassiveEvent(
        this.canvas_, 'touchmove', this.onTouchMove_.bind(this));
    enrollNonPassiveEvent(
        this.canvas_, 'touchend', this.onTouchEnd_.bind(this));
    enrollNonPassiveEvent(
        this.canvas_, 'touchcancel', this.onTouchCancel_.bind(this));

    const /** string */ pxString = `${CHART_MARGIN}px`;
    const /** string */ marginString = `${pxString} ${pxString} 0 ${pxString}`;
    this.canvas_.style.margin = marginString;
  }

  /**
   * Mouse and touchpad scroll event. Horizontal scroll for chart scrolling,
   * vertical scroll for chart zooming.
   * @param {Event} event
   */
  onWheel_(event) {
    event.preventDefault();
    /* WheelEvent.deltaMode will never be set to anything else but
     * DOM_DELTA_PIXEL. See crbug.com/227454 */
    if (event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL) {
      console.warn(
          'WheelEvent.deltaMode is not set to WheelEvent.DOM_DELTA_PIXEL.');
    }
    const wheelX = event.deltaX / MOUSE_WHEEL_UNITS;
    const wheelY = -event.deltaY / MOUSE_WHEEL_UNITS;
    this.scroll(MOUSE_WHEEL_SCROLL_RATE * wheelX);
    this.zoom(Math.pow(ZOOM_RATE, -wheelY));
  }

  /**
   * The following three functions handle mouse dragging event.
   * @param {Event} event
   */
  onMouseDown_(event) {
    event.preventDefault();
    this.isDragging_ = true;
    this.dragX_ = event.clientX;
  }

  /**
   * @param {Event} event
   */
  onMouseMove_(event) {
    event.preventDefault();
    if (!this.isDragging_) {
      return;
    }
    const /** number */ dragDeltaX = event.clientX - this.dragX_;
    this.scroll(DRAG_RATE * dragDeltaX);
    this.dragX_ = event.clientX;
  }

  /**
   * @param {Event} event
   */
  onMouseUpOrOut_(event) {
    this.isDragging_ = false;
  }

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

  /**
   * The following four functions handle touch events. One finger for
   * scrolling, two finger for zooming.
   * @param {Event} event
   */
  onTouchStart_(/** Event*/ event) {
    event.preventDefault();
    this.isTouching_ = true;
    const /** TouchList */ touches = event.targetTouches;
    if (touches.length === 1) {
      this.touchX_ = touches[0].clientX;
    } else if (touches.length === 2) {
      this.touchZoomBase_ =
          this.constructor.touchDistance_(touches[0], touches[1]);
    }
  }

  /**
   * @param {Event} event
   */
  onTouchMove_(event) {
    event.preventDefault();
    if (!this.isTouching_) {
      return;
    }
    const /** TouchList */ touches = event.targetTouches;
    if (touches.length === 1) {
      const /** number */ dragDeltaX = this.touchX_ - touches[0].clientX;
      this.scroll(DRAG_RATE * dragDeltaX);
      this.touchX_ = touches[0].clientX;
    } else if (touches.length === 2) {
      const /** number */ newDistance =
          this.constructor.touchDistance_(touches[0], touches[1]);
      const /** number */ zoomDelta =
          (this.touchZoomBase_ - newDistance) / TOUCH_ZOOM_UNITS;
      this.zoom(Math.pow(ZOOM_RATE, zoomDelta));
      this.touchZoomBase_ = newDistance;
    }
  }

  /**
   * @param {Event} event
   */
  onTouchEnd_(event) {
    event.preventDefault();
    this.isTouching_ = false;
  }

  /**
   * @param {Event} event
   */
  onTouchCancel_(event) {
    event.preventDefault();
    this.isTouching_ = false;
  }

  /**
   * Zoom the line chart by setting the |scale| to |rate| times.
   * @param {number} rate
   */
  zoom(rate) {
    const /** number */ oldScale = this.scale_;
    const /** number */ newScale = this.scale_ * rate;
    this.scale_ = Math.max(MIN_SCALE, Math.min(newScale, MAX_SCALE));

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

    if (this.scrollbar_.isScrolledToRightEdge()) {
      this.updateScrollBar_();
      this.scrollbar_.scrollToRightEdge();
      this.update();
      return;
    }

    /* To try to make the chart keep right, make the right edge of the chart
     * stop at the same position. */
    const /** number */ oldPosition = this.scrollbar_.getPosition();
    const /** number */ width = this.canvas_.width;
    const /** number */ visibleEndTime = oldScale * (oldPosition + width);
    const /** number */ newPosition =
        Math.round(visibleEndTime / this.scale_) - width;

    this.updateScrollBar_();
    this.scrollbar_.setPosition(newPosition);

    this.update();
  }

  /**
   * Scroll the line chart by moving forward |delta| pixels.
   * @param {number} delta
   */
  scroll(delta) {
    const /** number */ oldPosition = this.scrollbar_.getPosition();
    const /** number */ newPosition = oldPosition + Math.round(delta);

    this.scrollbar_.setPosition(newPosition);
    if (this.scrollbar_.getPosition() === oldPosition) {
      return;
    }

    this.update();
  }

  /**
   * Handle window resize event.
   */
  onResize_() {
    this.resize_();
    this.update();
  }

  /**
   * Handle |Menu| update event.
   */
  onMenuUpdate_() {
    this.resize_();
    this.update();
  }

  resize_() {
    const width = this.getChartVisibleWidth();
    const height = this.getChartVisibleHeight();
    if (this.canvas_.width === width && this.canvas_.height === height) {
      return;
    }

    this.canvas_.width = width;
    this.canvas_.height = height;
    const /** number */ scrollBarWidth = width + 2 * CHART_MARGIN;
    this.scrollbar_.resize(scrollBarWidth);
    this.updateScrollBar_();
  }

  /**
   * Update the end time of the line chart. This will update the scrollable
   * range of the chart. If the original position is at the right edge, the
   * chart will automatically scroll to right edge after updating.
   * @param {number} endTime
   */
  updateEndTime(endTime) {
    this.endTime_ = endTime;
    this.updateScrollBar_();
    this.update();
  }

  /**
   * Update the scrollbar to the current line chart status after zooming,
   * scrolling... etc.
   */
  updateScrollBar_() {
    const /** number */ scrollRange =
        Math.max(this.getChartWidth_() - this.getChartVisibleWidth(), 0);
    const /** boolean */ isScrolledToRightEdge =
        this.scrollbar_.isScrolledToRightEdge();
    this.scrollbar_.setRange(scrollRange);
    if (isScrolledToRightEdge && !this.isDragging_) {
      this.scrollbar_.scrollToRightEdge();
    }
  }

  /**
   * Get the whole line chart width, in pixel.
   * @return {number}
   */
  getChartWidth_() {
    const /** number */ timeRange = this.endTime_ - this.startTime_;

    const /** number */ numOfPixels = Math.floor(timeRange / this.scale_);
    const /** number */ sampleRate = SAMPLE_RATE;
    /* To reduce CPU usage, the chart do not draw points at every pixels.
     * Remove the last few pixels to avoid the graph showing some blank at
     * the end of the graph. */
    const /** number */ extraPixels = numOfPixels % sampleRate;
    return numOfPixels - extraPixels;
  }

  /**
   * Get the visible chart width, the width we need to render to the canvas, in
   * pixel.
   * @return {number}
   */
  getChartVisibleWidth() {
    return this.rootDiv_.offsetWidth - CHART_MARGIN * 2 - this.menu_.getWidth();
  }

  /**
   * Get the visible chart height.
   * @return {number}
   */
  getChartVisibleHeight() {
    return this.rootDiv_.offsetHeight - CHART_MARGIN -
        this.scrollbar_.getHeight();
  }

  /**
   * Set or reset the |units| and the |unitBase| of the |SubChart|.
   * @param {number} align - The align side of the subchart.
   * @param {Array<string>} units - See |UnitLabel|.
   * @param {number} unitBase - See |UnitLabel|.
   */
  setSubChart(align, units, unitBase) {
    this.clearSubChart(align);
    const /** UnitLabel */ label = new UnitLabel(units, unitBase);
    this.subCharts_[align] = new SubChart(label, align);
    this.update();
  }

  /**
   * Overwrite the maximum value of the sub chart. See |SubChart.setMaxValue|.
   * @param {number} align - The align side of the subchart.
   * @param {number|null} maxValue
   */
  setSubChartMaxValue(align, maxValue) {
    if (this.subCharts_[align]) {
      this.subCharts_[align].setMaxValue(maxValue);
    }
  }

  /**
   * Clear all subcharts and data series in the line chart.
   */
  clearAllSubChart() {
    for (let /** number */ i = 0; i < this.subCharts_.length; ++i) {
      this.clearSubChart(i);
    }
    this.update();
  }

  /**
   * Clear a single subchart and its data series.
   * @param {number} align - The align side of the subchart.
   */
  clearSubChart(align) {
    const /** SubChart */ oldSubChart = this.subCharts_[align];
    if (oldSubChart) {
      const /** Array<DataSeries> */ dataSeriesList =
          oldSubChart.getDataSeriesList();
      for (let /** number */ i = 0; i < dataSeriesList.length; ++i) {
        this.menu_.removeDataSeries(dataSeriesList[i]);
      }
    }
    this.subCharts_[align] = null;
    this.update();
  }

  /**
   * Add a data series to a subchart of the line chart. Call |setSubChart|
   * before calling this function.
   * @param {number} align - The align side of the subchart.
   * @param {DataSeries} dataSeries
   */
  addDataSeries(align, dataSeries) {
    const /** Array<SubChart> */ subCharts = this.subCharts_;
    if (subCharts[align] == null) {
      console.warn(
          'This sub chart has not been setup yet. ' +
          'Call |setSubChart| before calling this function.');
      return;
    }
    this.subCharts_[align].addDataSeries(dataSeries);
    this.menu_.addDataSeries(dataSeries);
    this.update();
  }

  /**
   * 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.
   */
  update() {
    clearTimeout(this.chartUpdateTimer_);
    if (!this.shouldRender()) {
      return;
    }
    this.chartUpdateTimer_ = setTimeout(this.render_.bind(this));
  }

  /**
   * Return true if any subchart need to be rendered.
   * @return {boolean}
   */
  shouldRender() {
    const subCharts = this.subCharts_;
    for (let /** number */ i = 0; i < subCharts.length; ++i) {
      if (subCharts[i] != null && subCharts[i].shouldRender()) {
        return true;
      }
    }
    return false;
  }

  /**
   * Implementation of line chart rendering.
   */
  render_() {
    const /** CanvasRenderingContext2D */ context =
        this.canvas_.getContext('2d');
    const /** number */ width = this.canvas_.width;
    const /** number */ height = this.canvas_.height;

    this.initAndClearContext_(context, width, height);

    const /** number */ fontHeight = parseInt(this.canvasStyle_.fontSize, 10);
    if (!fontHeight) {
      console.warn(
          'Render failed. Cannot get the font height from the canvas ' +
          'font style string.');
      return;
    }

    let /** number */ position = this.scrollbar_.getPosition();
    if (this.scrollbar_.getRange() === 0) {
      /* If the chart width less than the visible width, make the chart align
       * right by setting the negative position. */
      position = this.getChartWidth_() - this.canvas_.width;
    }
    const /** number */ visibleStartTime =
        this.startTime_ + position * this.scale_;
    const /** number */ graphHeight =
        height - fontHeight - MIN_LABEL_VERTICAL_SPACING;
    this.renderTimeLabels_(
        context, width, graphHeight, fontHeight, visibleStartTime);
    this.renderSubCharts_(
        context, width, graphHeight, fontHeight, visibleStartTime, position);
    this.renderChartGrid_(context, width, graphHeight);
  }

  /**
   * @param {CanvasRenderingContext2D} context
   * @param {number} width
   * @param {number} height
   */
  initAndClearContext_(context, width, height) {
    context.font = this.canvasStyle_.getPropertyValue('font');
    context.lineWidth = 2;
    context.lineCap = 'round';
    context.lineJoin = 'round';
    context.fillStyle = BACKGROUND_COLOR;
    context.fillRect(0, 0, width, height);
  }

  /**
   * Render the time label under the line chart.
   * @param {CanvasRenderingContext2D} context
   * @param {number} width
   * @param {number} height
   * @param {number} fontHeight
   * @param {number} startTime - The start time of the time label.
   */
  renderTimeLabels_(context, width, height, fontHeight, startTime) {
    const /** string */ sampleText = (new Date(startTime)).toLocaleTimeString();
    const /** number */ minSpacing = context.measureText(sampleText).width +
        MIN_TIME_LABEL_HORIZONTAL_SPACING;
    const /** number */ timeStep =
        this.constructor.getSuitableTimeStep_(minSpacing, this.scale_);
    if (timeStep === 0) {
      console.warn('Render time label failed. Cannot find suitable time unit.');
      return;
    }

    context.textBaseline = 'bottom';
    context.textAlign = 'center';
    context.fillStyle = TEXT_COLOR;
    context.strokeStyle = GRID_COLOR;
    context.beginPath();
    const /** number */ yCoord =
        height + fontHeight + MIN_LABEL_VERTICAL_SPACING;
    const /** number */ firstTimeTick =
        Math.ceil(startTime / timeStep) * timeStep;
    let /** number */ time = firstTimeTick;
    while (true) {
      const /** number */ xCoord = Math.round((time - startTime) / this.scale_);
      if (xCoord >= width) {
        break;
      }
      const /** string */ text = (new Date(time)).toLocaleTimeString();
      context.fillText(text, xCoord, yCoord);
      context.moveTo(xCoord, 0);
      context.lineTo(xCoord, height - 1);
      time += timeStep;
    }
    context.stroke();
  }

  /**
   * Find the suitable step of time to render the time label.
   * @param {number} minSpacing - The minimum spacing between two time tick.
   * @param {number} scale - The scale of the line chart.
   * @return {number}
   */
  static getSuitableTimeStep_(minSpacing, scale) {
    const /** Array<number> */ timeStepUnits = TIME_STEP_UNITS;
    let /** number */ timeStep = 0;
    for (let /** number */ i = 0; i < timeStepUnits.length; ++i) {
      if (timeStepUnits[i] / scale >= minSpacing) {
        timeStep = timeStepUnits[i];
        break;
      }
    }
    return timeStep;
  }

  /**
   * @param {CanvasRenderingContext2D} context
   * @param {number} graphWidth
   * @param {number} graphHeight
   */
  renderChartGrid_(context, graphWidth, graphHeight) {
    context.strokeStyle = GRID_COLOR;
    context.strokeRect(0, 0, graphWidth - 1, graphHeight);
  }

  /**
   * Render the subcharts and their data series.
   * @param {CanvasRenderingContext2D} context
   * @param {number} graphWidth
   * @param {number} graphHeight
   * @param {number} fontHeight
   * @param {number} visibleStartTime
   * @param {number} position - The scrollbar position.
   */
  renderSubCharts_(
      context, graphWidth, graphHeight, fontHeight, visibleStartTime,
      position) {
    const /** Array<SubChart> */ subCharts = this.subCharts_;
    /* To reduce CPU usage, the chart do not draw points at every pixels. Use
     * |offset| to make sure the graph won't shaking during scrolling, the line
     * chart will render the data points at the same absolute position. */
    const /** number */ offset = position % SAMPLE_RATE;
    for (let /** number */ i = 0; i < subCharts.length; ++i) {
      if (subCharts[i] === undefined) {
        continue;
      }
      subCharts[i].setLayout(
          graphWidth, graphHeight, fontHeight, visibleStartTime, this.scale_,
          offset);
      subCharts[i].renderLines(context);
      subCharts[i].renderUnitLabels(context);
    }
  }
}