chromium/ash/webui/diagnostics_ui/resources/realtime_cpu_chart.ts

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

import './diagnostics_shared.css.js';
import './strings.m.js';
import 'chrome://resources/d3/d3.min.js';

import {PolymerElementProperties} from '//resources/polymer/v3_0/polymer/interfaces.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {loadTimeData} from 'chrome://resources/ash/common/load_time_data.m.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './realtime_cpu_chart.html.js';

export interface ChartPadding {
  top: number;
  right: number;
  bottom: number;
  left: number;
  tick: number;
}

const RealtimeCpuChartElementBase = I18nMixin(PolymerElement);

/**
 * @fileoverview
 * 'realtime-cpu-chart' is a moving stacked area graph component used to display
 * a realtime cpu usage information.
 */

export class RealtimeCpuChartElement extends RealtimeCpuChartElementBase {
  static get is(): 'realtime-cpu-chart' {
    return 'realtime-cpu-chart' as const;
  }

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

  static get properties(): PolymerElementProperties {
    return {
      user: {
        type: Number,
        value: 0,
      },

      system: {
        type: Number,
        value: 0,
      },

      numDataPoints: {
        type: Number,
        value: 50,
      },

      dataRefreshPerSecond: {
        type: Number,
        value: 2,
      },

      /**
       * Chart rendering frames per second.
       * Strongly preferred to be multiples of dataRefreshPerSecond. If not,
       * there will be a small (hard to notice) jittering at every data refresh.
       */
      framesPerSecond: {
        type: Number,
        value: 30,
      },

      /**
       * Duration of each frame in milliseconds.
       */
      frameDuration: {
        readOnly: true,
        type: Number,
        computed: 'getFrameDuration(dataRefreshPerSecond, framesPerSecond)',
      },

      width: {
        type: Number,
        value: 560,
      },

      height: {
        type: Number,
        value: 114,
      },

      padding: {
        type: Object,
        value: {top: 10, right: 5, bottom: 8, left: 50, tick: 10},
      },

      graphWidth: {
        readOnly: true,
        type: Number,
        computed: 'getGraphDimension(width, padding.left, padding.right)',
      },

      graphHeight: {
        readOnly: true,
        type: Number,
        computed: 'getGraphDimension(height, padding.top, padding.bottom)',
      },

    };
  }

  user: number;
  system: number;
  private numDataPoints: number;
  private dataRefreshPerSecond: number;
  private framesPerSecond: number;
  private frameDuration: number;
  private width: number;
  private height: number;
  private padding: ChartPadding;
  private graphWidth: number;
  private graphHeight: number;
  // Helper function to map range of x coordinates to graph width.
  private xAxisScaleFn: d3.ScaleLinear<number, number> = d3.scaleLinear();
  // Helper function to map range of y coordinates to graph height.
  private yAxisScaleFn: d3.ScaleLinear<number, number> = d3.scaleLinear();
  private data: Array<{user: number, system: number}> = [];
  // DOMHighResTimeStamp of last graph render.
  private lastRender: number = 0;
  // Current render frame out of this.framesPerSecond.
  private currentFrame: number = 0;
  // Y-Values where we should mark ticks for the y-axis on the left.
  private yAxisTicks: number[] = [0, 25, 50, 75, 100];

  constructor() {
    super();

    // Initialize the data array with data outside the chart boundary.
    // Note that with side nav DOM manipulation, created() isn't guaranteed to
    // be called only once.
    this.data = [];
    for (let i = 0; i < this.numDataPoints; ++i) {
      this.data.push({user: -1, system: -1});
    }
  }

  static get observers(): string[] {
    return ['setScaling(graphWidth)'];
  }

  override ready(): void {
    super.ready();
    this.setScaling();
    this.initializeChart();
    window.addEventListener('resize', () => this.updateChartWidth());

    // Set the initial chart width.
    this.updateChartWidth();
  }

  private updateChartWidth(): void {
    // parseFloat() is used to convert the string returned by
    // getComputedStyleValue() into a number ("642px" --> 642).
    const width = parseFloat(
        window.getComputedStyle(this).getPropertyValue('--chart-width-nav'));
    if (!isNaN(width)) {
      this.width = width;
    }
  }

  /**
   * Calculate the duration of each frame in milliseconds.
   */
  protected getFrameDuration(): number {
    assert(this.dataRefreshPerSecond > 0);
    assert(this.framesPerSecond > 0);
    assert(this.framesPerSecond % this.dataRefreshPerSecond === 0);
    return 1000 / (this.framesPerSecond / this.dataRefreshPerSecond);
  }

  /**
   * Get actual graph dimensions after accounting for margins.
   */
  private getGraphDimension(base: number, ...margins: number[]): number {
    return margins.reduce(((acc, margin) => acc - margin), base);
  }

  /**
   * Sets scaling functions that convert data -> svg coordinates.
   */
  private setScaling(): void {
    // Map y-values [0, 100] to [graphHeight, 0] inverse linearly.
    // Data value of 0 will map to graphHeight, 100 maps to 0.
    this.yAxisScaleFn =
        d3.scaleLinear().domain([0, 100]).range([this.graphHeight, 0]);

    // Map x-values [0, numDataPoints - 3] to [0, graphWidth] linearly.
    // Data value of 0 maps to 0, and (numDataPoints - 2) maps to graphWidth.
    // numDataPoints is subtracted since 1) data array is zero based, and
    // 2) to smooth out the curve function.
    this.xAxisScaleFn =
        d3.scaleLinear().domain([0, this.numDataPoints - 2]).range([
          0,
          this.graphWidth,
        ]);

    // Draw the y-axis legend and also draw the horizontal gridlines by
    // reversing the ticks back into the chart body.
    const chartGroup: d3.Selection<Element|null, unknown, null, undefined>|any =
        d3.select(this.shadowRoot!.querySelector('#chartGroup'));
    assert(chartGroup);
    chartGroup.select('#gridLines')
        .call(
            d3.axisLeft(this.yAxisScaleFn)
                .tickValues(this.yAxisTicks)
                .tickFormat((y) => this.getPercentageLabel((y as number)))
                .tickPadding(this.padding.tick)
                .tickSize(-this.graphWidth),  // Extend the ticks into the
                                              // entire graph as gridlines.
        );
  }

  private initializeChart(): void {
    const chartGroup: d3.Selection<Element|null, unknown, null, undefined> =
        d3.select(this.shadowRoot!.querySelector('#chartGroup'));
    assert(chartGroup);

    // Position chartGroup inside the margin.
    chartGroup.attr(
        'transform', `translate(${this.padding.left},${this.padding.top})`);

    const plotGroup: d3.Selection<Element|null, unknown, null, undefined> =
        d3.select(this.shadowRoot!.querySelector('#plotGroup'));
    assert(plotGroup);

    // Feed data array to the plot group.
    plotGroup.datum(this.data);

    // Select each area and configure the transition for animation.
    // d3.transition API @ https://github.com/d3/d3-transition#d3-transition.
    // d3.easing API @ https://github.com/d3/d3-ease#api-reference.
    plotGroup.select('.user-area')
        .transition()
        .duration(this.frameDuration)
        .ease((t: number) => +t);  // Linear transition
    plotGroup.select('.system-area')
        .transition()
        .duration(this.frameDuration)
        .ease((t: number) => +t);  // Linear transition

    // Draw initial data and kick off the rendering process.
    this.getDataSnapshotAndRedraw();
    this.render(/*timeStamp=*/ 0);
  }

  private getAreaDefinition(areaClass: string):
      d3.ValueFn<SVGPathElement, any, any> {
    return d3
        .area()
        // Take the index of each data as x values.
        .x((_, i) => this.xAxisScaleFn(i))
        // Bottom coordinates of each area. System area extends down to -1
        // instead of 0 to avoid the area border from showing up.
        .y0((data: any) => this?.yAxisScaleFn(
                areaClass === 'system-area' ? -1 : data?.system))
        // Top coordinates of each area.
        .y1((data: any) => this?.yAxisScaleFn(
                areaClass === 'system-area' ? data?.system :
                                              data?.system + data?.user));
  }

  /**
   * Takes a snapshot of current CPU readings and appends to the data array for
   * redrawing. This method is called after each transition cycle.
   */
  private getDataSnapshotAndRedraw(): void {
    this.data.push({user: this.user, system: this.system});
    this.data.shift();

    const userArea: d3.Selection<Element|null, unknown, null, undefined>|any =
        this.shadowRoot!.querySelector(`path.user-area`);
    assert(userArea);
    const systemArea: d3.Selection<Element|null, unknown, null, undefined>|any =
        this.shadowRoot!.querySelector(`path.system-area`);
    assert(systemArea);
    d3.select(userArea).attr('d', this.getAreaDefinition('user-area'));
    d3.select(systemArea).attr('d', this.getAreaDefinition('system-area'));
  }


  private render(timeStamp: number): void {
    // Re-render only when this.frameDuration has passed since last render.
    // If we acquire the animation frame before this, do nothing.
    if (timeStamp - this.lastRender > this.frameDuration) {
      this.lastRender = performance.now();

      // Get new data and redraw on each cycle.
      const framesPerCycle = this.framesPerSecond / this.dataRefreshPerSecond;
      if (this.currentFrame === framesPerCycle) {
        this.currentFrame = 0;
        this.getDataSnapshotAndRedraw();
      }

      const userArea: d3.Selection<Element|null, unknown, null, undefined>|any =
          this.shadowRoot!.querySelector(`path.user-area`);
      assert(userArea);
      const systemArea: d3.Selection<Element|null, unknown, null, undefined>|
          any = this.shadowRoot!.querySelector(`path.system-area`);
      assert(systemArea);

      // Calculate the new position. Use this.currentFrame + 1 since on frame
      // 0, it is already at position 0.
      const pos = -1 * ((this.currentFrame + 1) / framesPerCycle);

      // Slide it to the left
      d3.select(userArea).attr(
          'transform', 'translate(' + this.xAxisScaleFn(pos) + ',0)');
      d3.select(systemArea)
          .attr('transform', 'translate(' + this.xAxisScaleFn(pos) + ',0)');
      this.currentFrame++;
    }

    // Request another frame.
    requestAnimationFrame((timeStamp) => this.render(timeStamp));
  }

  private getPercentageLabel(value: number): string {
    return loadTimeData.getStringF('percentageLabel', value);
  }

  getPaddingForTesting(): ChartPadding {
    return this.padding;
  }

  getFrameDurationForTesting(): number {
    return this.frameDuration;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [RealtimeCpuChartElement.is]: RealtimeCpuChartElement;
  }
}

customElements.define(RealtimeCpuChartElement.is, RealtimeCpuChartElement);