chromium/chrome/browser/resources/chromeos/sys_internals/line_chart/unit_label.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 {MAX_VERTICAL_LABEL_NUM, MIN_LABEL_VERTICAL_SPACING} from './constants.js';

/**
 * Create by |LineChart.LineChart|.
 * A scalable label which can calculate the suitable unit and generate text
 * labels.
 * @const
 */
export class UnitLabel {
  constructor(/** Array<string> */ units, /** number */ unitBase) {
    /** @const {Array<string>} - See |getSuitableUnit()|. */
    this.units_ = units;
    if (units.length === 0) {
      console.warn('LineChart.UnitLabel: Length of units must greater than 0.');
    }

    /** @const {number} - See |getSuitableUnit()|. */
    this.unitBase_ = unitBase;
    if (unitBase < 0) {
      console.warn('LineChart.UnitLabel: unitBase must greater than 0.');
    }

    /**
     * The current max value for this label. To calculate the suitable units.
     * @type {number}
     */
    this.maxValue_ = 0;

    /** @type {number} - The cache of maxValue. See |setMaxValue()|. */
    this.maxValueCache_ = 0;

    /** @type {number} - The current suitable unit's index. */
    this.currentUnitIdx_ = 0;

    /** @type {Array<string>} - The generated text labels. */
    this.labels_ = [];

    /** @type {number} - The height of the label, in pixels. */
    this.height_ = 0;

    /** @type {number} - The font height of the label, in pixels. */
    this.fontHeight_ = 1;

    /** @type {number} - The maximum precision for the number of the label. */
    this.precision_ = 1;

    /** @type {number} - See |getScale()|. */
    this.scale_ = 1;

    /** @type {boolean} True if the label need not be regenerated. */
    this.isCache_ = false;
  }

  /**
   * Get the generated text labels.
   * @return {Array<string>}
   */
  getLabels() {
    this.updateLabelsAndScale_();
    return this.labels_;
  }

  /**
   * The scale of the real value and the y coordinate of the chart.
   * @return {number}
   */
  getScale() {
    this.updateLabelsAndScale_();
    return this.scale_;
  }

  /**
   * Get current suitable unit.
   * @return {string}
   */
  getCurrentUnitString() {
    return this.units_[this.currentUnitIdx_];
  }

  /**
   * Set the layout of the label. See |LineChart.SubChart.setLayout()|.
   * @param {number} height - The label height, in pixels.
   * @param {number} fontHeight - The font height, in pixels.
   * @param {number} precision - The maximum precision of the value of label.
   *      It means that the minimum step size of the label is |10^(-percision)|.
   */
  setLayout(height, fontHeight, precision) {
    if (precision < 0 || precision > 20) {
      console.warn('Precision must be between 0 and 20.');
      return;
    }
    if (this.height_ === height && this.fontHeight_ === fontHeight &&
        this.precision_ === precision) {
      return;
    }

    this.height_ = height;
    this.fontHeight_ = fontHeight;
    this.precision_ = precision;
    this.isCache_ = false;
  }

  /**
   * Set the maximum value of the label. Decide the suitable unit by this value.
   * @param {number} maxValue
   */
  setMaxValue(maxValue) {
    if (this.maxValueCache_ === maxValue) {
      return;
    }
    this.maxValueCache_ = maxValue;

    const /** Array<string> */ units = this.units_;
    const /** number */ unitBase = this.unitBase_;
    const /** {unitIdx: number, value: number} */ result =
        this.constructor.getSuitableUnit(maxValue, units, unitBase);
    this.currentUnitIdx_ = result.unitIdx;
    this.maxValue_ = result.value;
    this.isCache_ = false;
  }

  /**
   * Find the suitable unit. If the value is greater than |unitBase|, we will
   * try to use a bigger unit.
   * @param {number} value - The original value.
   * @param {Array<string>} units - The unit set. Ex: ['B', 'KB', 'MB'].
   * @param {number} unitBase - The base of the units. It means the next unit
   *     is |unitBase| times of current unit.
   *     Ex: The |unitBase| of ['B', 'KB', 'MB'] is 1024.
   *
   * @return {{unitIdx: number, value: number}}
   */
  static getSuitableUnit(value, units, unitBase) {
    let /** number */ unitIdx = 0;
    while (unitIdx + 1 < units.length && value >= unitBase) {
      value /= unitBase;
      ++unitIdx;
    }
    return {
      unitIdx: unitIdx,
      value: value,
    };
  }

  /**
   * Update the labels and scale if the status is changed.
   */
  updateLabelsAndScale_() {
    if (this.isCache_) {
      return;
    }
    this.isCache_ = true;

    if (this.maxValue_ === 0) {
      return;
    }

    const /** {stepSize: number, stepSizePrecision: number} */ result =
        this.getSuitableStepSize_();
    const /** number */ stepSize = result.stepSize;
    const /** number */ stepSizePrecision = result.stepSizePrecision;

    const /** number */ topLabelValue =
        this.getTopLabelValue_(this.maxValue_, stepSize);
    const /** string */ unitStr = this.getCurrentUnitString();
    const /** Array<string> */ labels = [];
    for (let /** number */ value = topLabelValue; value >= 0;
         value -= stepSize) {
      const /** string */ valueStr = value.toFixed(stepSizePrecision);
      const /** string */ label = valueStr + ' ' + unitStr;
      labels.push(label);
    }
    this.labels_ = labels;

    const /** number */ realTopValue =
        this.getRealValueWithCurrentUnit_(topLabelValue);
    this.scale_ = this.height_ / realTopValue;
  }

  /**
   * Find a step size to show a suitable amount of labels on screen. The default
   * step size according to the precision of the label. We will try 1 time, 2
   * times and 5 tims of the default step size. If they are not suitable, we
   * will reduce the precision and try again.
   * @return {{stepSize: number, stepSizePrecision: number}}
   */
  getSuitableStepSize_() {
    const /** number */ maxValue = this.maxValue_;
    const /** number */ maxLabelNum = this.getMaxNumberOfLabel_();
    let /** number */ stepSize = Math.pow(10, -this.precision_);

    /**
     * This number is for Number.toFixed. if precision is less than 0, it is set
     * to 0.
     * @type {number}
     */
    let stepSizePrecision = Math.max(this.precision_, 0);
    while (true) {
      if (this.getNumberOfLabelWithStepSize_(stepSize) <= maxLabelNum) {
        break;
      }
      if (this.getNumberOfLabelWithStepSize_(stepSize * 2) <= maxLabelNum) {
        stepSize *= 2;
        break;
      }
      if (this.getNumberOfLabelWithStepSize_(stepSize * 5) <= maxLabelNum) {
        stepSize *= 5;
        break;
      }

      /* Reduce the precision. */
      stepSize *= 10;
      if (stepSizePrecision > 0) {
        --stepSizePrecision;
      }
    }

    return {
      stepSize: stepSize,
      stepSizePrecision: stepSizePrecision,
    };
  }

  /**
   * Get the maximun number of equally spaced labels. |fontHeight_| is doubled
   * because the top two labels are both drawn in the same gap.
   * @return {number}
   */
  getMaxNumberOfLabel_() {
    const /** number */ minLabelSpacing =
        2 * this.fontHeight_ + MIN_LABEL_VERTICAL_SPACING;
    let /** number */ maxLabelNum =
        1 + Math.floor(this.height_ / minLabelSpacing);
    if (maxLabelNum < 2) {
      maxLabelNum = 2;
    } else if (maxLabelNum > MAX_VERTICAL_LABEL_NUM) {
      maxLabelNum = MAX_VERTICAL_LABEL_NUM;
    }

    return maxLabelNum;
  }

  /**
   * Get the number of labels with |stepSize|. Because we want the top of the
   * label to be an exact multiple of the |stepSize|, we use |Math.ceil() + 1|
   * to add an additional label above the |maxValue|. See |getTopLabelValue_()|.
   * @param {number} stepSize
   * @return {number}
   */
  getNumberOfLabelWithStepSize_(stepSize) {
    const /** number */ maxValue = this.maxValue_;
    return Math.ceil(maxValue / stepSize) + 1;
  }

  /**
   * Top label value is an exact multiple of |stepSize|.
   * @param {number} maxValue
   * @param {number} stepSize
   * @return {number}
   */
  getTopLabelValue_(maxValue, stepSize) {
    return Math.ceil(maxValue / stepSize) * stepSize;
  }

  /**
   * Transform the value in the current suitable unit to the real value.
   * @param {number} value
   * @return {number}
   */
  getRealValueWithCurrentUnit_(value) {
    return value * Math.pow(this.unitBase_, this.currentUnitIdx_);
  }
}