chromium/chrome/browser/resources/chromeos/healthd_internals/line_chart/utils/data_series.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 {assert} from '//resources/js/assert.js';

/**
 * The interface of data points displayed in the line chart.
 */
export interface DataPoint {
  value: number;
  time: number;
}

/**
 * Get the average point from a range of points.
 */
function getAveragePoint(points: DataPoint[]): DataPoint {
  const valueSum: number = points.reduce((acc, item) => acc + item.value, 0);
  const timeSum: number = points.reduce((acc, item) => acc + item.time, 0);
  return {value: valueSum / points.length, time: timeSum / points.length};
}

/**
 * The helper class to collect data points, to record title and color and to get
 * the required values for displaying on the line chart.
 */
export class DataSeries {
  constructor(title: string, color: string) {
    this.displayedTitle = title;
    this.displayedColor = color;
  }

  // The name of this data series.
  private readonly displayedTitle: string;
  // The color of this data series.
  private readonly displayedColor: string;

  // All the data points of the data series. Sorted by time.
  private dataPoints: DataPoint[] = [];

  // Whether the data is visible on the line chart.
  private isVisible: boolean = true;

  // We will report the displayed points based on the cached data below. If the
  // cached data is the same, we don't need to calculate the points again.
  private cachedStartTime: number = 0;
  private cachedEndTime: number = 0;
  private cachedStepSize: number = 0;

  // Used to display partial line chart on the canvas.
  private displayedPoints: DataPoint[] = [];

  // The maximum value of `displayedPoints` value.
  private maxValue: number = 0;

  // Add a new data point to this data series. The time must be greater than the
  // time of the last data point in the data series.
  addDataPoint(value: number, time: number) {
    if (!isFinite(value) || !isFinite(time)) {
      console.warn(
          'Add invalid data to DataSeries: value: ' + value +
          ', time: ' + time);
      return;
    }
    const length: number = this.dataPoints.length;
    if (length > 0 && this.dataPoints[length - 1].time > time) {
      console.warn(
          'Add invalid time to DataSeries: ' + time +
          '. Time must be non-strictly increasing.');
      return;
    }
    this.dataPoints.push({value: value, time: time});
  }

  // Control the visibility of data series.
  setVisible(isVisible: boolean) {
    this.isVisible = isVisible;
  }

  getVisible(): boolean {
    return this.isVisible;
  }

  getTitle(): string {
    return this.displayedTitle;
  }

  getColor(): string {
    return this.displayedColor;
  }

  /**
   * Get the displayed points to draw on line chart.
   *
   * @param startTime - The start time for the displayed part of chart.
   * @param endTime - The end time for the displayed part of chart.
   * @param stepSize - The step size in millisecond. We will only display one
   *                   point in one step.
   * @return - The displayed points.
   */
  getDisplayedPoints(startTime: number, endTime: number, stepSize: number):
      DataPoint[] {
    if (!this.isVisible) {
      return [];
    }
    this.updateCachedData(startTime, endTime, stepSize);
    return this.displayedPoints;
  }

  // Get the maximum value of the displayed points. See `getDisplayedPoints()`
  // for details of arguments.
  getDisplayedMaxValue(startTime: number, endTime: number, stepSize: number):
      number {
    if (!this.isVisible) {
      return 0;
    }
    this.updateCachedData(startTime, endTime, stepSize);
    return this.maxValue;
  }

  // Implementation of querying the displayed points and the max value.
  private updateCachedData(
      startTime: number, endTime: number, stepSize: number) {
    if (this.cachedStartTime === startTime && this.cachedEndTime === endTime &&
        this.cachedStepSize === stepSize) {
      return;
    }

    this.displayedPoints =
        this.getDisplayedPointsInner(startTime, endTime, stepSize);
    this.maxValue = this.displayedPoints.reduce(
        (maxValue, item) => Math.max(maxValue, item.value), 0);

    // Updated the cached value after the data is updated.
    this.cachedStartTime = startTime;
    this.cachedEndTime = endTime;
    this.cachedStepSize = stepSize;
  }

  private getDisplayedPointsInner(
      startTime: number, endTime: number, stepSize: number): DataPoint[] {
    const output: DataPoint[] = [];
    let pointsInStep: DataPoint[] = [];
    // Helper function to collect one point in the current step.
    const storeDisplayedPoint = () => {
      if (pointsInStep.length !== 0) {
        output.push(getAveragePoint(pointsInStep));
        pointsInStep = [];
      }
    };

    // To avoid shaking, we need to minus the offset (`startTime % stepSize`) to
    // keep every step the same under the same `stepSize`.
    // Also minus `stepSize` to collect one more point before `startTime` to
    // avoid showing blank at the start.
    let currentStepStart: number = startTime - startTime % stepSize - stepSize;
    let currentIndex: number = this.findLowerBoundPointIndex(currentStepStart);

    // Return empty point list as there is no visible point after `startTime`.
    if (currentIndex >= this.dataPoints.length) {
      return [];
    }

    // If there is no point in the step before `startTime`, we will collect one
    // more point before `currentIndex`.
    if (this.dataPoints[currentIndex].time > startTime && currentIndex >= 1) {
      output.push(this.dataPoints[currentIndex - 1]);
    }

    while (currentIndex < this.dataPoints.length) {
      // Collect one more point outside the visible time to avoid showing blank
      // at the end.
      if (output.length !== 0 && output[output.length - 1].time >= endTime) {
        break;
      }

      const currentPoint: DataPoint = this.dataPoints[currentIndex];
      // Time of current point should be greater than or equal to
      // `currentStepStart`. See `findLowerBoundPointIndex()` for more details.
      assert(currentPoint.time >= currentStepStart);

      // Collect the points in [`currentStepStart`, `currentStepEnd`).
      const currentStepEnd: number = currentStepStart + stepSize;
      if (currentPoint.time >= currentStepEnd) {
        storeDisplayedPoint();
        currentStepStart = currentStepEnd;
      } else {
        pointsInStep.push(currentPoint);
        currentIndex += 1;
      }
    }
    storeDisplayedPoint();
    return output;
  }

  /**
   * Find the minimum index of point which time is greater than or equal to
   * `time` by simple binary search.
   */
  private findLowerBoundPointIndex(time: number): number {
    let lower: number = 0;
    let upper: number = this.dataPoints.length;
    while (lower < upper) {
      const mid: number = Math.floor((lower + upper) / 2);
      const point: DataPoint = this.dataPoints[mid];
      if (time <= point.time) {
        upper = mid;
      } else {
        lower = mid + 1;
      }
    }
    return lower;
  }

  /**
   * Filter out data points which the `time` field is earlier than `startTime`.
   * Keep an additional buffer of data points to avoid modifying `dataPoints`
   * too frequently.
   *
   * @param startTime - The new start time. Data points which time before this
   *                    should be removed.
   * @returns - Whether any data points are removed.
   */
  removeOutdatedData(startTime: number): boolean {
    // Retain one hour more of data points as buffer so we only need to update
    // `dataPoints` every hour.
    const dataRetentionBuffer: number = 1 * 60 * 60 * 1000;

    // Find the index of the first data point within the buffer.
    const bufferStartIndex: number =
        this.findLowerBoundPointIndex(startTime - dataRetentionBuffer);

    // If there are points outside the buffer, remove them and points in buffer.
    if (bufferStartIndex > 0) {
      const newStartIndex: number = this.findLowerBoundPointIndex(startTime);
      assert(newStartIndex >= bufferStartIndex);
      this.dataPoints.splice(0, newStartIndex);
      return true;
    }
    return false;
  }
}