chromium/chrome/browser/resources/chromeos/healthd_internals/line_chart/utils/canvas_drawer.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 {BACKGROUND_COLOR, GRID_COLOR, MIN_LABEL_HORIZONTAL_SPACING, MIN_LABEL_VERTICAL_SPACING, MIN_TIME_LABEL_HORIZONTAL_SPACING, MIN_TIME_SCALE, SAMPLE_RATE, TEXT_COLOR, TEXT_SIZE, TIME_STEP_UNITS, Y_AXIS_TICK_LENGTH} from '../configs.js';

import type {DataPoint} from './data_series.js';
import {DataSeries} from './data_series.js';
import {UnitLabel} from './unit_label.js';

/**
 * Find the minimum time step for rendering time labels.
 *
 * @param minSpacing - The minimum spacing between two time tick.
 * @param timeScale - The horizontal scale of the line chart.
 */
function getMinimumTimeStep(minSpacing: number, timeScale: number): number {
  const timeStepUnits: number[] = TIME_STEP_UNITS;
  let timeStep: number = 0;
  for (let i: number = 0; i < timeStepUnits.length; ++i) {
    if (timeStepUnits[i] / timeScale >= minSpacing) {
      timeStep = timeStepUnits[i];
      break;
    }
  }
  return timeStep;
}
/**
 * Get the step size based on the current time scale. We will only display one
 * point in one step.
 *
 * @param timeScale - The horizontal scale of the line chart.
 * @returns - The step size in millisecond.
 */
function getStepSize(timeScale: number): number {
  const baseStepSize: number = MIN_TIME_SCALE * SAMPLE_RATE;
  const minStepSize: number = timeScale * SAMPLE_RATE;
  const minExponent = Math.log(minStepSize / baseStepSize) / Math.LN2;
  // Round up to the next `baseStepSize` multiplied by the power of 2 to avoid
  // shaking during zooming.
  return baseStepSize * Math.pow(2, Math.ceil(minExponent));
}

/**
 * Helper class to draw the canvas from data series which share the same unit
 * set. This class is responsible for drawing lines, time labels and unit labels
 * on the line chart.
 */
export class CanvasDrawer {
  constructor(units: string[], unitBase: number) {
    this.unitLabel = new UnitLabel(units, unitBase);
  }

  // Set in constructor.
  private readonly unitLabel: UnitLabel;

  // List of displayed data.
  private dataSeriesList: DataSeries[] = [];

  // The fixed maximum value in line chart. If this value is null, the maximum
  // value of unit label will be set from the real maximum value of data series.
  private fixedMaxValue: number|null = null;

  // The width and height of the graph for drawing line chart, excluding the
  // bottom labels.
  private graphWidth: number = 1;
  private graphHeight: number = 1;

  // Add a data series to this sub chart.
  addDataSeries(dataSeries: DataSeries) {
    this.dataSeriesList.push(dataSeries);
  }

  // Overwrite the maximum value of this chart.
  setFixedMaxValue(maxValue: number|null) {
    this.fixedMaxValue = maxValue;
  }

  // Return true if there is any data series in this chart.
  shouldRender(): boolean {
    return this.dataSeriesList.length > 0;
  }

  /**
   * Render the canvas content.
   *
   * @param context - 2D rendering context for the drawing the line chart.
   * @param canvasWidth - The width of canvas element.
   * @param canvasHeight - The height of canvas element.
   * @param visibleStartTime - The start time of visible part of line chart.
   * @param visibleEndTime - The end time of visible part of line chart.
   * @param timeScale - The number of milliseconds between two pixels.
   */
  renderCanvas(
      context: CanvasRenderingContext2D, canvasWidth: number,
      canvasHeight: number, visibleStartTime: number, visibleEndTime: number,
      timeScale: number) {
    this.initAndClearContext(context, canvasWidth, canvasHeight);

    this.graphWidth = canvasWidth;
    this.graphHeight = canvasHeight - TEXT_SIZE - MIN_LABEL_VERTICAL_SPACING;

    this.renderChartGrid(context);
    this.renderTimeLabels(context, visibleStartTime, timeScale);

    const stepSize: number = getStepSize(timeScale);
    const maxValue: number =
        this.getVisibleMaxValue(visibleStartTime, visibleEndTime, stepSize);
    this.renderUnitLabel(context, maxValue);
    this.renderLines(
        context, visibleStartTime, visibleEndTime, timeScale, stepSize);
  }

  private initAndClearContext(
      context: CanvasRenderingContext2D, canvasWidth: number,
      canvasHeight: number) {
    context.font = `${TEXT_SIZE}px Arial`;
    context.lineWidth = 2;
    context.lineCap = 'round';
    context.lineJoin = 'round';
    context.fillStyle = BACKGROUND_COLOR;
    context.fillRect(0, 0, canvasWidth, canvasHeight);
  }

  private renderChartGrid(context: CanvasRenderingContext2D) {
    context.strokeStyle = GRID_COLOR;
    context.strokeRect(0, 0, this.graphWidth - 1, this.graphHeight);
  }

  // Render the time label under the line chart.
  private renderTimeLabels(
      context: CanvasRenderingContext2D, startTime: number, timeScale: number) {
    const sampleText: string = new Date(startTime).toLocaleTimeString();
    const minSpacing: number = context.measureText(sampleText).width +
        MIN_TIME_LABEL_HORIZONTAL_SPACING;
    const timeStep: number = getMinimumTimeStep(minSpacing, timeScale);
    if (timeStep === 0) {
      console.warn('Render time label failed. Cannot find minimum time unit.');
      return;
    }

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

  // Render lines for all data series.
  private renderLines(
      context: CanvasRenderingContext2D, startTime: number, endTime: number,
      timeScale: number, stepSize: number) {
    for (const dataSeries of this.dataSeriesList) {
      this.renderLine(
          context, dataSeries, startTime, endTime, timeScale, stepSize);
    }
  }

  // Render the line for one data series.
  private renderLine(
      context: CanvasRenderingContext2D, dataSeries: DataSeries,
      startTime: number, endTime: number, timeScale: number, stepSize: number) {
    // Query the the values of data points from the data series.
    const dataPoints: DataPoint[] =
        dataSeries.getDisplayedPoints(startTime, endTime, stepSize);
    if (dataPoints.length === 0) {
      return;
    }

    context.strokeStyle = dataSeries.getColor();
    context.fillStyle = dataSeries.getColor();
    context.beginPath();

    const valueScale: number = this.unitLabel.getValueScale();
    for (const point of dataPoints) {
      const xCoord: number = Math.round((point.time - startTime) / timeScale);
      const chartYCoord: number = Math.round(point.value / valueScale);
      const realYCoord: number = this.graphHeight - 1 - chartYCoord;
      context.lineTo(xCoord, realYCoord);
    }
    context.stroke();
    const firstXCoord: number =
        Math.round((dataPoints[0].time - startTime) / timeScale);
    const lastXCoord: number = Math.round(
        (dataPoints[dataPoints.length - 1].time - startTime) / timeScale);
    this.fillAreaBelowLine(context, firstXCoord, lastXCoord);
  }

  private fillAreaBelowLine(
      context: CanvasRenderingContext2D, firstXCoord: number,
      lastXCoord: number) {
    context.lineTo(lastXCoord, this.graphHeight);
    context.lineTo(firstXCoord, this.graphHeight);
    context.globalAlpha = 0.05;
    context.fill();
    context.globalAlpha = 1.0;
  }

  // Render the unit label on the right side of line chart.
  private renderUnitLabel(context: CanvasRenderingContext2D, maxValue: number) {
    this.unitLabel.setMaxValue(maxValue);

    // Cannot draw the line at the top and the bottom pixel.
    const labelHeight: number = this.graphHeight - 2;
    this.unitLabel.setLayout(labelHeight, /* precision */ 2);

    const labelTexts: string[] = this.unitLabel.getLabels();
    if (labelTexts.length === 0) {
      return;
    }

    context.textAlign = 'right';
    const tickStartX: number = this.graphWidth - 1;
    const tickEndX: number = this.graphWidth - 1 - Y_AXIS_TICK_LENGTH;
    const textXCoord: number = this.graphWidth - MIN_LABEL_HORIZONTAL_SPACING;
    const labelYStep: number = this.graphHeight / (labelTexts.length - 1);

    this.renderLabelTicks(
        context, labelTexts, labelYStep, tickStartX, tickEndX);
    this.renderLabelTexts(context, labelTexts, labelYStep, textXCoord);
  }

  // Calculate the max value for the current layout of unit label.
  private getVisibleMaxValue(
      startTime: number, endTime: number, stepSize: number): number {
    if (this.fixedMaxValue != null) {
      return this.fixedMaxValue;
    }
    return this.dataSeriesList.reduce(
        (maxValue, item) => Math.max(
            maxValue, item.getDisplayedMaxValue(startTime, endTime, stepSize)),
        0);
  }

  // Render the tick line for the unit label.
  private renderLabelTicks(
      context: CanvasRenderingContext2D, labelTexts: string[],
      labelYStep: number, tickStartX: number, tickEndX: number) {
    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 i: number = 1; i < labelTexts.length - 1; ++i) {
      const yCoord: number = labelYStep * i;
      context.moveTo(tickStartX, yCoord);
      context.lineTo(tickEndX, yCoord);
    }
    context.stroke();
  }

  // Render the texts for the unit label.
  private renderLabelTexts(
      context: CanvasRenderingContext2D, labelTexts: string[],
      labelYStep: number, textXCoord: number) {
    // 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 i: number = 1; i < labelTexts.length; ++i) {
      const yCoord: number = labelYStep * i;
      context.fillText(labelTexts[i], textXCoord, yCoord);
    }
  }
}