chromium/chrome/browser/resources/chromeos/sys_internals/line_chart/sub_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 {GRID_COLOR, MIN_LABEL_HORIZONTAL_SPACING, SAMPLE_RATE, TEXT_COLOR, UnitLabelAlign, Y_AXIS_TICK_LENGTH} from './constants.js';
import {DataSeries} from './data_series.js';
import {UnitLabel} from './unit_label.js';

/**
 * Create by |LineChart|.
 * Maintains data series which share the same |UnitLabel|, that is,
 * share the same unit set. Also, this object is responsible for drawing the
 * line and the unit label on the line chart.
 * @const
 */
export class SubChart {
  constructor(/** UnitLabel */ label, /** number */ labelAlign) {
    /** @const {UnitLabel} */
    this.label_ = label;

    /** @const {number} */
    this.labelAlign_ = labelAlign;

    /** @type {Array<DataSeries>} */
    this.dataSeriesList_ = [];

    /**
     * The step size between two data points, in millisecond.
     * @type {number}
     */
    this.stepSize_ = 1;

    /**
     * The time to query the data. It will be smaller than the
     * |visibleStartTime|, so the line chart won't leave blanks beside the edge
     * of the chart.
     * @type {number}
     */
    this.queryStartTime_ = 0;

    /**
     * The offset of the current visible range. To make sure we draw the data
     * points at the same absolute position. See also
     * |renderSubCharts_()|.
     * @type {number}
     */
    this.offset_ = 0;

    /** @type {number} */
    this.width_ = 1;

    /** @type {number} */
    this.height_ = 1;

    /**
     * Number of the points need to be draw on the screen.
     * @type {number}
     */
    this.numOfPoint_ = 0;

    /**
     * See |setMaxValue|.
     * @type {number|null}
     */
    this.maxValue_ = null;
  }

  /**
   * Set the layout of this sub chart. Call this function when something changed
   * like the window size, visible range, scale ...etc. See |LineChart|for the
   * parameters' details.
   * @param {number} width
   * @param {number} height
   * @param {number} fontHeight
   * @param {number} visibleStartTime
   * @param {number} scale
   * @param {number} offset - See |renderSubChart_()|.
   */
  setLayout(width, height, fontHeight, visibleStartTime, scale, offset) {
    this.width_ = width;
    this.height_ = height;
    this.offset_ = offset;
    const /** number */ sampleRate = SAMPLE_RATE;

    /* Draw a data point on every |sampleRate| pixels. */
    this.stepSize_ = scale * sampleRate;

    /* First point's position(|queryStartTime|) may go out of the canvas to
     * make the line chart continuous at the begin of the visible range, as well
     * as the last points. */
    this.queryStartTime_ = visibleStartTime - offset * scale;
    const /** number */ queryWidth = width + offset;
    this.numOfPoint_ = Math.ceil(queryWidth / sampleRate) + 1;

    /* Cannot draw the line at the top and the bottom pixel. */
    const labelHeight = height - 2;
    this.label_.setLayout(labelHeight, fontHeight, /* precision */ 2);
    this.updateMaxValue_();
  }

  /**
   * Overwrite the maximum value of this sub chart. If this value is not null,
   * the maximum value of the unit label will be set to this value instead of
   * the real maximum value of data series.
   * @param {number|null} maxValue
   */
  setMaxValue(maxValue) {
    this.maxValue_ = maxValue;
    this.updateMaxValue_();
  }

  /**
   * Calculate the max value for the current layout.
   */
  updateMaxValue_() {
    const /** Array<DataSeries> */ dataSeriesList = this.dataSeriesList_;
    if (this.maxValue_ != null) {
      this.label_.setMaxValue(this.maxValue_);
      return;
    }
    let /** number */ maxValue = 0;
    for (let /** number */ i = 0; i < dataSeriesList.length; ++i) {
      const value = this.getMaxValueFromDataSeries_(dataSeriesList[i]);
      maxValue = Math.max(maxValue, value);
    }
    this.label_.setMaxValue(maxValue);
  }

  /**
   * Query the max value of the query range from the data series.
   * @param {DataSeries} dataSeries
   * @return {number}
   */
  getMaxValueFromDataSeries_(dataSeries) {
    if (!dataSeries.isVisible()) {
      return 0;
    }
    return dataSeries.getMaxValue(
        this.queryStartTime_, this.stepSize_, this.numOfPoint_);
  }

  /**
   * Add a data series to this sub chart.
   * @param {DataSeries} dataSeries
   */
  addDataSeries(dataSeries) {
    this.dataSeriesList_.push(dataSeries);
  }

  /**
   * Get all data series of this sub chart.
   * @return {Array<DataSeries>}
   */
  getDataSeriesList() {
    return this.dataSeriesList_;
  }

  /**
   * Render the lines of all data series.
   * @param {CanvasRenderingContext2D} context
   */
  renderLines(context) {
    const /** Array<DataSeries> */ dataSeriesList = this.dataSeriesList_;
    for (let /** number */ i = 0; i < dataSeriesList.length; ++i) {
      const /** Array<number> */ values =
          this.getValuesFromDataSeries_(dataSeriesList[i]);
      if (!values) {
        continue;
      }
      this.renderLineOfDataSeries_(context, dataSeriesList[i], values);
    }
  }

  /**
   * Query the the data points' values from the data series.
   * @param {DataSeries} dataSeries
   * @return {Array<number>}
   */
  getValuesFromDataSeries_(dataSeries) {
    if (!dataSeries.isVisible()) {
      return [];
    }
    return dataSeries.getValues(
        this.queryStartTime_, this.stepSize_, this.numOfPoint_);
  }

  /**
   * @param {CanvasRenderingContext2D} context
   * @param {DataSeries} dataSeries
   * @param {Array<number>} values
   */
  renderLineOfDataSeries_(context, dataSeries, values) {
    context.strokeStyle = dataSeries.getColor();
    context.fillStyle = dataSeries.getColor();
    context.beginPath();

    const /** number */ sampleRate = SAMPLE_RATE;
    const /** number */ valueScale = this.label_.getScale();
    let /** number */ firstXCoord = this.width_;
    let /** number */ xCoord = -this.offset_;
    for (let /** number */ i = 0; i < values.length; ++i) {
      if (values[i] != null) {
        const /** number */ chartYCoord = Math.round(values[i] * valueScale);
        const /** number */ realYCoord = this.height_ - 1 - chartYCoord;
        context.lineTo(xCoord, realYCoord);
        if (firstXCoord > xCoord) {
          firstXCoord = xCoord;
        }
      }
      xCoord += sampleRate;
    }
    context.stroke();
    this.fillAreaBelowLine_(context, firstXCoord);
  }

  /**
   * @param {CanvasRenderingContext2D} context
   * @param {number} firstXCoord
   */
  fillAreaBelowLine_(context, firstXCoord) {
    context.lineTo(this.width_, this.height_);
    context.lineTo(firstXCoord, this.height_);
    context.globalAlpha = 0.2;
    context.fill();
    context.globalAlpha = 1.0;
  }

  /**
   * @param {CanvasRenderingContext2D} context
   */
  renderUnitLabels(context) {
    const /** Array<string> */ labelTexts = this.label_.getLabels();
    if (labelTexts.length === 0) {
      return;
    }

    let /** number */ tickStartX;
    let /** number */ tickEndX;
    let /** number */ textXCoord;
    if (this.labelAlign_ === UnitLabelAlign.LEFT) {
      context.textAlign = 'left';
      tickStartX = 0;
      tickEndX = Y_AXIS_TICK_LENGTH;
      textXCoord = MIN_LABEL_HORIZONTAL_SPACING;
    } else if (this.labelAlign_ === UnitLabelAlign.RIGHT) {
      context.textAlign = 'right';
      tickStartX = this.width_ - 1;
      tickEndX = this.width_ - 1 - Y_AXIS_TICK_LENGTH;
      textXCoord = this.width_ - MIN_LABEL_HORIZONTAL_SPACING;
    } else {
      console.warn('Unknown label align.');
      return;
    }
    const /** number */ labelYStep = this.height_ / (labelTexts.length - 1);
    this.renderLabelTicks_(
        context, labelTexts, labelYStep, tickStartX, tickEndX);
    this.renderLabelTexts_(context, labelTexts, labelYStep, textXCoord);
  }

  /**
   * Render the tick line for the unit label.
   * @param {CanvasRenderingContext2D} context
   * @param {Array<string>} labelTexts
   * @param {number} labelYStep
   * @param {number} tickStartX
   * @param {number} tickEndX
   */
  renderLabelTicks_(context, labelTexts, labelYStep, tickStartX, tickEndX) {
    context.strokeStyle = GRID_COLOR;
    context.beginPath();
    /* First and last tick are the top and the bottom of the line chart, so
     * don't draw them again. */
    for (let /** number */ i = 1; i < labelTexts.length - 1; ++i) {
      const /** number */ yCoord = labelYStep * i;
      context.moveTo(tickStartX, yCoord);
      context.lineTo(tickEndX, yCoord);
    }
    context.stroke();
  }

  /**
   * Render the texts for the unit label.
   * @param {CanvasRenderingContext2D} context
   * @param {Array<string>} labelTexts
   * @param {number} labelYStep
   * @param {number} textXCoord
   */
  renderLabelTexts_(context, labelTexts, labelYStep, textXCoord) {
    /* The first label cannot align the bottom of the tick or it will go outside
     * the canvas. */
    context.fillStyle = TEXT_COLOR;
    context.textBaseline = 'top';
    context.fillText(labelTexts[0], textXCoord, 0);

    context.textBaseline = 'bottom';
    for (let /** number */ i = 1; i < labelTexts.length; ++i) {
      const /** number */ yCoord = labelYStep * i;
      context.fillText(labelTexts[i], textXCoord, yCoord);
    }
  }

  /**
   * Return true if there is any data series in this sub chart, whatever there
   * are visible or not.
   * @return {boolean}
   */
  shouldRender() {
    return this.dataSeriesList_.length > 0;
  }
}