Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-list/mr-chart/mr-chart.js b/static_src/elements/issue-list/mr-chart/mr-chart.js
new file mode 100644
index 0000000..a4c4189
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/mr-chart.js
@@ -0,0 +1,1041 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import qs from 'qs';
+import page from 'page';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import {linearRegression} from 'shared/math.js';
+import './chops-chart.js';
+import {urlWithNewParams, createObjectComparisonFunc} from 'shared/helpers.js';
+
+const DEFAULT_NUM_DAYS = 90;
+const SECONDS_IN_DAY = 24 * 60 * 60;
+const MAX_QUERY_SIZE = 90;
+const MAX_DISPLAY_LINES = 10;
+const predRangeType = Object.freeze({
+  NEXT_MONTH: 0,
+  NEXT_QUARTER: 1,
+  NEXT_50: 2,
+  HIDE: 3,
+});
+const CHART_OPTIONS = {
+  animation: false,
+  responsive: true,
+  title: {
+    display: true,
+    text: 'Issues over time',
+  },
+  tooltips: {
+    mode: 'x',
+    intersect: false,
+  },
+  hover: {
+    mode: 'x',
+    intersect: false,
+  },
+  legend: {
+    display: true,
+    labels: {
+      boxWidth: 15,
+    },
+  },
+  scales: {
+    xAxes: [{
+      display: true,
+      type: 'time',
+      time: {parser: 'MM/DD/YYYY', tooltipFormat: 'll'},
+      scaleLabel: {
+        display: true,
+        labelString: 'Day',
+      },
+    }],
+    yAxes: [{
+      display: true,
+      ticks: {
+        beginAtZero: true,
+      },
+      scaleLabel: {
+        display: true,
+        labelString: 'Value',
+      },
+    }],
+  },
+};
+const COLOR_CHOICES = ['#00838F', '#B71C1C', '#2E7D32', '#00659C',
+  '#5D4037', '#558B2F', '#FF6F00', '#6A1B9A', '#880E4F', '#827717'];
+const BG_COLOR_CHOICES = ['#B2EBF2', '#EF9A9A', '#C8E6C9', '#B2DFDB',
+  '#D7CCC8', '#DCEDC8', '#FFECB3', '#E1BEE7', '#F8BBD0', '#E6EE9C'];
+
+/**
+ * Set of serialized state this element should update for.
+ * mr-app lowercases all query parameters before putting into store.
+ * @type {Set<string>}
+ */
+export const subscribedQuery = new Set([
+  'start-date',
+  'end-date',
+  'groupby',
+  'labelprefix',
+  'q',
+  'can',
+]);
+
+const queryParamsHaveChanged = createObjectComparisonFunc(subscribedQuery);
+
+/**
+ * Mapping between query param's groupby value and chart application data.
+ * @type {Object}
+ */
+const groupByMapping = {
+  'open': {display: 'Is open', value: 'open'},
+  'owner': {display: 'Owner', value: 'owner'},
+  'comonent': {display: 'Component', value: 'component'},
+  'status': {display: 'Status', value: 'status'},
+};
+
+/**
+ * `<mr-chart>`
+ *
+ * Component rendering the chart view
+ *
+ */
+export default class MrChart extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        max-width: 800px;
+        margin: 0 auto;
+      }
+      chops-chart {
+        max-width: 100%;
+      }
+      div#options {
+        max-width: 720px;
+        margin: 2em auto;
+        text-align: center;
+      }
+      div#options #unsupported-fields {
+        font-weight: bold;
+        color: orange;
+      }
+      div.align {
+        display: flex;
+      }
+      div.align #frequency, div.align #groupBy {
+        display: inline-block;
+        width: 40%;
+      }
+      div.align #frequency #two-toggle {
+        font-size: 95%;
+        text-align: center;
+        margin-bottom: 5px;
+      }
+      div.align #time, div.align #prediction {
+        display: inline-block;
+        width: 60%;
+      }
+      #dropdown {
+        height: 50%;
+      }
+      div.section {
+        display: inline-block;
+        text-align: center;
+      }
+      div.section.input {
+        padding: 4px 10px;
+      }
+      .menu {
+        min-width: 50%;
+        text-align: left;
+        font-size: 12px;
+        box-sizing: border-box;
+        text-decoration: none;
+        white-space: nowrap;
+        padding: 0.25em 8px;
+        transition: 0.2s background ease-in-out;
+        cursor: pointer;
+        color: var(--chops-link-color);
+      }
+      .menu:hover {
+        background: hsl(0, 0%, 90%);
+      }
+      .choice.transparent {
+        background: var(--chops-white);
+        border-color: var(--chops-choice-color);
+        border-radius: 4px;
+      }
+      .choice.shown {
+        background: var(--chops-active-choice-bg);
+      }
+      .choice {
+        padding: 4px 10px;
+        background: var(--chops-choice-bg);
+        color: var(--chops-choice-color);
+        text-decoration: none;
+        display: inline-block;
+      }
+      .choice.checked {
+        background: var(--chops-active-choice-bg);
+      }
+      p .warning-message {
+        display: none;
+        font-size: 1.25em;
+        padding: 0.25em;
+        background-color: var(--chops-orange-50);
+      }
+      progress {
+        background-color: var(--chops-white);
+        border: 1px solid var(--chops-gray-500);
+        margin: 0 0 1em;
+        width: 100%;
+        visibility: visible;
+      }
+      ::-webkit-progress-bar {
+        background-color: var(--chops-white);
+      }
+      progress::-webkit-progress-value {
+        transition: width 1s;
+        background-color: #00838F;
+      }
+    `;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('queryParams')) {
+      this._setPropsFromQueryParams();
+      this._fetchData();
+    }
+  }
+
+  /** @override */
+  render() {
+    const doneLoading = this.progress === 1;
+    return html`
+      <chops-chart
+        type="line"
+        .options=${CHART_OPTIONS}
+        .data=${this._chartData(this.indices, this.values)}
+      ></chops-chart>
+      <div id="options">
+        <p id="unsupported-fields">
+          ${this.unsupportedFields.length ? `
+            Unsupported fields: ${this.unsupportedFields.join(', ')}`: ''}
+        </p>
+        <progress
+          value=${this.progress}
+          ?hidden=${doneLoading}
+        >Loading chart...</progress>
+        <p class="warning-message" ?hidden=${!this.searchLimitReached}>
+          Note: Some results are not being counted.
+          Please narrow your query.
+        </p>
+        <p class="warning-message" ?hidden=${!this.maxQuerySizeReached}>
+          Your query is too long.
+          Showing ${MAX_QUERY_SIZE} weeks from end date.
+        </p>
+        <p class="warning-message" ?hidden=${!this.dateRangeNotLegal}>
+          Your requested date range does not exist.
+          Showing ${MAX_QUERY_SIZE} days from end date.
+        </p>
+        <p class="warning-message" ?hidden=${!this.cannedQueryOpen}>
+          Your query scope prevents closed issues from showing.
+        </p>
+        <div class="align">
+          <div id="frequency">
+            <label for="two-toggle">Choose date range:</label>
+            <div id="two-toggle">
+              <chops-button @click="${this._setDateRange.bind(this, 180)}"
+                class="${this.dateRange === 180 ? 'choice checked': 'choice'}">
+                180 Days
+              </chops-button>
+              <chops-button @click="${this._setDateRange.bind(this, 90)}"
+                class="${this.dateRange === 90 ? 'choice checked': 'choice'}">
+                90 Days
+              </chops-button>
+              <chops-button @click="${this._setDateRange.bind(this, 30)}"
+                class="${this.dateRange === 30 ? 'choice checked': 'choice'}">
+                30 Days
+              </chops-button>
+            </div>
+          </div>
+          <div id="time">
+            <label for="start-date">Choose start and end date:</label>
+            <br />
+            <input
+              type="date"
+              id="start-date"
+              name="start-date"
+              .value=${this.startDate && this.startDate.toISOString().substr(0, 10)}
+              ?disabled=${!doneLoading}
+              @change=${(e) => this.startDate = MrChart.dateStringToDate(e.target.value)}
+            />
+            <input
+              type="date"
+              id="end-date"
+              name="end-date"
+              .value=${this.endDate && this.endDate.toISOString().substr(0, 10)}
+              ?disabled=${!doneLoading}
+              @change=${(e) => this.endDate = MrChart.dateStringToDate(e.target.value)}
+            />
+            <chops-button @click="${this._onDateChanged}" class=choice>
+              Apply
+            </chops-button>
+          </div>
+        </div>
+        <div class="align">
+          <div id="prediction">
+          <label for="two-toggle">Choose prediction range:</label>
+          <div id="two-toggle">
+            ${this._renderPredictChoice('Future Month', predRangeType.NEXT_MONTH)}
+            ${this._renderPredictChoice('Future Quarter', predRangeType.NEXT_QUARTER)}
+            ${this._renderPredictChoice('Future 50%', predRangeType.NEXT_50)}
+            ${this._renderPredictChoice('Hide', predRangeType.HIDE)}
+          </div>
+        </div>
+          <div id="groupBy">
+            <label for="dropdown">Choose group by:</label>
+            <mr-dropdown
+              id="dropdown"
+              ?disabled=${!doneLoading}
+              .text=${this.groupBy.display}
+            >
+              ${this.dropdownHTML}
+            </mr-dropdown>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * Renders a single prediction button.
+   * @param {string} choiceName The text displayed on the button.
+   * @param {number} rangeType An enum-like number specifying which range
+   *   to use.
+   * @return {TemplateResult}
+   */
+  _renderPredictChoice(choiceName, rangeType) {
+    const changePrediction = (_e) => {
+      this.predRange = rangeType;
+      this._fetchData();
+    };
+    return html`
+      <chops-button
+        @click=${changePrediction}
+        class="${this.predRange === rangeType ? 'checked': ''} choice">
+        ${choiceName}
+      </chops-button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      progress: {type: Number},
+      projectName: {type: String},
+      hotlistId: {type: Number},
+      indices: {type: Array},
+      values: {type: Array},
+      unsupportedFields: {type: Array},
+      dateRangeNotLegal: {type: Boolean},
+      dateRange: {type: Number},
+      frequency: {type: Number},
+      queryParams: {
+        type: Object,
+        hasChanged: queryParamsHaveChanged,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.progress = 0.05;
+    this.values = [];
+    this.indices = [];
+    this.unsupportedFields = [];
+    this.predRange = predRangeType.HIDE;
+    this._page = page;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    if (!this.projectName && !this.hotlistId) {
+      throw new Error('Attribute `projectName` or `hotlistId` required.');
+    }
+    this._setPropsFromQueryParams();
+    this._constructDropdownMenu();
+  }
+
+  /**
+   * Initialize queryParams and set properties from the queryParams.
+   * Since this page exists in both the SPA and ezt they initialize mr-chart
+   * differently, ie in ezt, this.queryParams will be undefined during
+   * connectedCallback. Until ezt is deleted, initialize props here.
+   */
+  _setPropsFromQueryParams() {
+    if (!this.queryParams) {
+      const params = qs.parse(document.location.search.substring(1));
+      // ezt pages used querystring as source of truth
+      // and 'labelPrefix'in query param, but SPA uses
+      // redux store's sitewide.queryParams as source of truth
+      // and lowercases all keys in sitewide.queryParams
+      if (params.hasOwnProperty('labelPrefix')) {
+        const labelPrefixValue = params['labelPrefix'];
+        params['labelprefix'] = labelPrefixValue;
+        delete params['labelPrefix'];
+      }
+      this.queryParams = params;
+    }
+    this.endDate = MrChart.getEndDate(this.queryParams['end-date']);
+    this.startDate = MrChart.getStartDate(
+        this.queryParams['start-date'],
+        this.endDate, DEFAULT_NUM_DAYS);
+    this.groupBy = MrChart.getGroupByFromQuery(this.queryParams);
+  }
+
+  /**
+   * Set dropdown options menu in HTML.
+   */
+  async _constructDropdownMenu() {
+    const response = await this._getLabelPrefixes();
+    let dropdownOptions = ['None', 'Component', 'Is open', 'Status', 'Owner'];
+    dropdownOptions = dropdownOptions.concat(response);
+    const dropdownHTML = dropdownOptions.map((str) => html`
+      <option class='menu' @click=${this._setGroupBy}>
+        ${str}</option>`);
+    this.dropdownHTML = html`${dropdownHTML}`;
+  }
+
+  /**
+   * Call global page.js to change frontend route based on new parameters
+   * @param {Object<string, string>} newParams
+   */
+  _changeUrlParams(newParams) {
+    const newUrl = urlWithNewParams(`/p/${this.projectName}/issues/list`,
+        this.queryParams, newParams);
+    this._page(newUrl);
+  }
+
+  /**
+   * Set start date and end date and trigger url action
+   */
+  _onDateChanged() {
+    const newParams = {
+      'start-date': this.startDate.toISOString().substr(0, 10),
+      'end-date': this.endDate.toISOString().substr(0, 10),
+    };
+    this._changeUrlParams(newParams);
+  }
+
+  /**
+   * Fetch data required to render chart
+   * @fires Event#allDataLoaded
+   */
+  async _fetchData() {
+    this.dateRange = Math.ceil(
+        (this.endDate - this.startDate) / (1000 * SECONDS_IN_DAY));
+
+    // Coordinate different params and flags, protect against illegal queries
+    // Case for start date greater than end date.
+    if (this.dateRange <= 0) {
+      this.frequency = 7;
+      this.dateRangeNotLegal = true;
+      this.maxQuerySizeReached = false;
+      this.dateRange = MAX_QUERY_SIZE;
+    } else {
+      this.dateRangeNotLegal = false;
+      if (this.dateRange >= MAX_QUERY_SIZE * 7) {
+        // Case for date range too long, requires >= MAX_QUERY_SIZE queries.
+        this.frequency = 7;
+        this.maxQuerySizeReached = true;
+        this.dateRange = MAX_QUERY_SIZE * 7;
+      } else {
+        this.maxQuerySizeReached = false;
+        if (this.dateRange < MAX_QUERY_SIZE) {
+          // Case for small date range, displayed in daily frequency.
+          this.frequency = 1;
+        } else {
+          // Case for medium date range, displayed in weekly frequency.
+          this.frequency = 7;
+        }
+      }
+    }
+    // Set canned query flag.
+    this.cannedQueryOpen = (this.queryParams.can === '2' &&
+      this.groupBy.value === 'open');
+
+    // Reset chart variables except indices.
+    this.progress = 0.05;
+
+    let numTimestampsLoaded = 0;
+    const timestampsChronological = MrChart.makeTimestamps(this.endDate,
+        this.frequency, this.dateRange);
+    const tsToIndexMap = new Map(timestampsChronological.map((ts, idx) => (
+      [ts, idx]
+    )));
+    this.indices = MrChart.makeIndices(timestampsChronological);
+    const timestamps = MrChart.sortInBisectOrder(timestampsChronological);
+    this.values = new Array(timestamps.length).fill(undefined);
+
+    const fetchPromises = timestamps.map(async (ts) => {
+      const data = await this._fetchDataAtTimestamp(ts);
+      const index = tsToIndexMap.get(ts);
+      this.values[index] = data.issues;
+      numTimestampsLoaded += 1;
+      const progressValue = numTimestampsLoaded / timestamps.length;
+      this.progress = progressValue;
+
+      return data;
+    });
+
+    const chartData = await Promise.all(fetchPromises);
+
+    // This is purely for testing purposes
+    this.dispatchEvent(new Event('allDataLoaded'));
+
+    // Check if the query includes any field values that are not supported.
+    const flatUnsupportedFields = chartData.reduce((acc, datum) => {
+      if (datum.unsupportedField) {
+        acc = acc.concat(datum.unsupportedField);
+      }
+      return acc;
+    }, []);
+    this.unsupportedFields = Array.from(new Set(flatUnsupportedFields));
+
+    this.searchLimitReached = chartData.some((d) => d.searchLimitReached);
+  }
+
+  /**
+   * fetch data at timestamp
+   * @param {number} timestamp
+   * @return {{date: number, issues: Array<Map.<string, number>>,
+   *   unsupportedField: string, searchLimitReached: string}}
+   */
+  async _fetchDataAtTimestamp(timestamp) {
+    const query = this.queryParams.q;
+    const cannedQuery = this.queryParams.can;
+    const message = {
+      timestamp: timestamp,
+      projectName: this.projectName,
+      query: query,
+      cannedQuery: cannedQuery,
+      hotlistId: this.hotlistId,
+      groupBy: undefined,
+    };
+    if (this.groupBy.value !== '') {
+      message['groupBy'] = this.groupBy.value;
+      if (this.groupBy.value === 'label') {
+        message['labelPrefix'] = this.groupBy.labelPrefix;
+      }
+    }
+    const response = await prpcClient.call('monorail.Issues',
+        'IssueSnapshot', message);
+
+    let issues;
+    if (response.snapshotCount) {
+      issues = response.snapshotCount.reduce((map, curr) => {
+        if (curr.dimension !== undefined) {
+          if (this.groupBy.value === '') {
+            map.set('Issue Count', curr.count);
+          } else {
+            map.set(curr.dimension, curr.count);
+          }
+        }
+        return map;
+      }, new Map());
+    } else {
+      issues = new Map();
+    }
+    return {
+      date: timestamp * 1000,
+      issues: issues,
+      unsupportedField: response.unsupportedField,
+      searchLimitReached: response.searchLimitReached,
+    };
+  }
+
+  /**
+   * Get prefixes from the set of labels.
+   */
+  async _getLabelPrefixes() {
+    // If no project (i.e. viewing a hotlist), return empty list.
+    if (!this.projectName) {
+      return [];
+    }
+
+    const projectRequestMessage = {
+      project_name: this.projectName};
+    const labelsResponse = await prpcClient.call(
+        'monorail.Projects', 'GetLabelOptions', projectRequestMessage);
+    const labelPrefixes = new Set();
+    for (let i = 0; i < labelsResponse.labelOptions.length; i++) {
+      const label = labelsResponse.labelOptions[i].label;
+      if (label.includes('-')) {
+        labelPrefixes.add(label.split('-')[0]);
+      }
+    }
+    return Array.from(labelPrefixes);
+  }
+
+  /**
+   * construct chart data
+   * @param {Array} indices
+   * @param {Array} values
+   * @return {Object} chart data and options
+   */
+  _chartData(indices, values) {
+    // Generate a map of each data line {dimension:string, value:array}
+    const mapValues = new Map();
+    for (let i = 0; i < values.length; i++) {
+      if (values[i] !== undefined) {
+        values[i].forEach((value, key, map) => mapValues.set(key, []));
+      }
+    }
+    // Count the number of 0 or undefined data points.
+    let count = 0;
+    for (let i = 0; i < values.length; i++) {
+      if (values[i] !== undefined) {
+        if (values[i].size === 0) {
+          count++;
+        }
+        // Set none-existing data points 0.
+        mapValues.forEach((value, key, map) => {
+          mapValues.set(key, value.concat([values[i].get(key) || 0]));
+        });
+      } else {
+        count++;
+      }
+    }
+    // Legend display set back to default.
+    CHART_OPTIONS.legend.display = true;
+    // Check if any positive valued data exist, if not, draw an array of zeros.
+    if (count === values.length) {
+      return {
+        type: 'line',
+        labels: indices,
+        datasets: [{
+          label: this.groupBy.labelPrefix,
+          data: Array(indices.length).fill(0),
+          backgroundColor: COLOR_CHOICES[0],
+          borderColor: COLOR_CHOICES[0],
+          showLine: true,
+          fill: false,
+        }],
+      };
+    }
+    // Convert map to a dataset of lines.
+    let arrayValues = [];
+    mapValues.forEach((value, key, map) => {
+      arrayValues.push({
+        label: key,
+        data: value,
+        backgroundColor: COLOR_CHOICES[arrayValues.length %
+          COLOR_CHOICES.length],
+        borderColor: COLOR_CHOICES[arrayValues.length % COLOR_CHOICES.length],
+        showLine: true,
+        fill: false,
+      });
+    });
+    arrayValues = MrChart.getSortedLines(arrayValues, MAX_DISPLAY_LINES);
+    if (this.predRange === predRangeType.HIDE) {
+      return {
+        type: 'line',
+        labels: indices,
+        datasets: arrayValues,
+      };
+    }
+
+    let predictedValues = [];
+    let originalData;
+    let predictedData;
+    let maxData;
+    let minData;
+    let currColor;
+    let currBGColor;
+    // Check if displayed values > MAX_DISPLAY_LINES, hide legend.
+    if (arrayValues.length * 4 > MAX_DISPLAY_LINES) {
+      CHART_OPTIONS.legend.display = false;
+    } else {
+      CHART_OPTIONS.legend.display = true;
+    }
+    for (let i = 0; i < arrayValues.length; i++) {
+      [originalData, predictedData, maxData, minData] =
+        MrChart.getAllData(indices, arrayValues[i]['data'], this.dateRange,
+            this.predRange, this.frequency, this.endDate);
+      currColor = COLOR_CHOICES[i % COLOR_CHOICES.length];
+      currBGColor = BG_COLOR_CHOICES[i % COLOR_CHOICES.length];
+      predictedValues = predictedValues.concat([{
+        label: arrayValues[i]['label'],
+        backgroundColor: currColor,
+        borderColor: currColor,
+        data: originalData,
+        showLine: true,
+        fill: false,
+      }, {
+        label: arrayValues[i]['label'].concat(' prediction'),
+        backgroundColor: currColor,
+        borderColor: currColor,
+        borderDash: [5, 5],
+        data: predictedData,
+        pointRadius: 0,
+        showLine: true,
+        fill: false,
+      }, {
+        label: arrayValues[i]['label'].concat(' lower error'),
+        backgroundColor: currBGColor,
+        borderColor: currBGColor,
+        borderDash: [5, 5],
+        data: minData,
+        pointRadius: 0,
+        showLine: true,
+        hidden: true,
+        fill: false,
+      }, {
+        label: arrayValues[i]['label'].concat(' upper error'),
+        backgroundColor: currBGColor,
+        borderColor: currBGColor,
+        borderDash: [5, 5],
+        data: maxData,
+        pointRadius: 0,
+        showLine: true,
+        hidden: true,
+        fill: '-1',
+      }]);
+    }
+    return {
+      type: 'scatter',
+      datasets: predictedValues,
+    };
+  }
+
+  /**
+   * Change group by based on dropdown menu selection.
+   * @param {Event} e
+   */
+  _setGroupBy(e) {
+    switch (e.target.text) {
+      case 'None':
+        this.groupBy = {value: undefined};
+        break;
+      case 'Is open':
+        this.groupBy = {value: 'open'};
+        break;
+      case 'Owner':
+      case 'Component':
+      case 'Status':
+        this.groupBy = {value: e.target.text.toLowerCase()};
+        break;
+      default:
+        this.groupBy = {value: 'label', labelPrefix: e.target.text};
+    }
+    this.groupBy['display'] = e.target.text;
+    this.shadowRoot.querySelector('#dropdown').text = e.target.text;
+    this.shadowRoot.querySelector('#dropdown').close();
+
+    const newParams = {
+      'groupby': this.groupBy.value,
+      'labelprefix': this.groupBy.labelPrefix,
+    };
+
+    this._changeUrlParams(newParams);
+  }
+
+  /**
+   * Change date range and frequency based on button clicked.
+   * @param {number} dateRange Number of days in date range
+   */
+  _setDateRange(dateRange) {
+    if (this.dateRange !== dateRange) {
+      this.startDate = new Date(
+          this.endDate.getTime() - 1000 * SECONDS_IN_DAY * dateRange);
+      this._onDateChanged();
+      window.getTSMonClient().recordDateRangeChange(dateRange);
+    }
+  }
+
+  /**
+   * Move first, last, and median to the beginning of the array, recursively.
+   * @param  {Array} timestamps
+   * @return {Array}
+   */
+  static sortInBisectOrder(timestamps) {
+    const arr = [];
+    if (timestamps.length === 0) {
+      return arr;
+    } else if (timestamps.length <= 2) {
+      return timestamps;
+    } else {
+      const beginTs = timestamps.shift();
+      const endTs = timestamps.pop();
+      const medianTs = timestamps.splice(timestamps.length / 2, 1)[0];
+      return [beginTs, endTs, medianTs].concat(
+          MrChart.sortInBisectOrder(timestamps));
+    }
+  }
+
+  /**
+   * Populate array of timestamps we want to fetch.
+   * @param {Date} endDate
+   * @param {number} frequency
+   * @param {number} numDays
+   * @return {Array}
+   */
+  static makeTimestamps(endDate, frequency, numDays=DEFAULT_NUM_DAYS) {
+    if (!endDate) {
+      throw new Error('endDate required');
+    }
+    const endTimeSeconds = Math.round(endDate.getTime() / 1000);
+    const timestampsChronological = [];
+    for (let i = 0; i < numDays; i += frequency) {
+      timestampsChronological.unshift(endTimeSeconds - (SECONDS_IN_DAY * i));
+    }
+    return timestampsChronological;
+  }
+
+  /**
+   * Convert a string '2018-11-03' to a Date object.
+   * @param  {string} dateString
+   * @return {Date}
+   */
+  static dateStringToDate(dateString) {
+    if (!dateString) {
+      return null;
+    }
+    const splitDate = dateString.split('-');
+    const year = Number.parseInt(splitDate[0]);
+    // Month is 0-indexed, so subtract one.
+    const month = Number.parseInt(splitDate[1]) - 1;
+    const day = Number.parseInt(splitDate[2]);
+    return new Date(Date.UTC(year, month, day, 23, 59, 59));
+  }
+
+  /**
+   * Returns a Date parsed from string input, defaults to current date.
+   * @param {string} input
+   * @return {Date}
+   */
+  static getEndDate(input) {
+    if (input) {
+      const date = MrChart.dateStringToDate(input);
+      if (date) {
+        return date;
+      }
+    }
+    const today = new Date();
+    today.setHours(23);
+    today.setMinutes(59);
+    today.setSeconds(59);
+    return today;
+  }
+
+  /**
+   * Return a Date parsed from string input
+   * defaults to diff days befores endDate
+   * @param {string} input
+   * @param {Date} endDate
+   * @param {number} diff
+   * @return {Date}
+   */
+  static getStartDate(input, endDate, diff) {
+    if (input) {
+      const date = MrChart.dateStringToDate(input);
+      if (date) {
+        return date;
+      }
+    }
+    return new Date(endDate.getTime() - 1000 * SECONDS_IN_DAY * diff);
+  }
+
+  /**
+   * Make indices
+   * @param {Array} timestamps
+   * @return {Array}
+   */
+  static makeIndices(timestamps) {
+    const dateFormat = {year: 'numeric', month: 'numeric', day: 'numeric'};
+    return timestamps.map((ts) => (
+      (new Date(ts * 1000)).toLocaleDateString('en-US', dateFormat)
+    ));
+  }
+
+  /**
+   * Generate predicted future data based on previous data.
+   * @param {Array} values
+   * @param {number} dateRange
+   * @param {number} interval
+   * @param {number} frequency
+   * @param {Date} inputEndDate
+   * @return {Array}
+   */
+  static getPredictedData(
+      values, dateRange, interval, frequency, inputEndDate) {
+    // TODO(weihanl): changes to support frequencies other than 1 and 7.
+    let n;
+    let endDateRange;
+    if (frequency === 1) {
+      // Display in daily.
+      n = values.length;
+      endDateRange = interval;
+    } else {
+      // Display in weekly.
+      n = Math.floor((DEFAULT_NUM_DAYS + 1) / 7);
+      endDateRange = interval * 7 - 1;
+    }
+    const [slope, intercept] = linearRegression(values, n);
+    const endDate = new Date(inputEndDate.getTime() +
+        1000 * SECONDS_IN_DAY * (1 + endDateRange));
+    const timestampsChronological = MrChart.makeTimestamps(
+        endDate, frequency, endDateRange);
+    const predictedIndices = MrChart.makeIndices(timestampsChronological);
+
+    // Obtain future data and past data on the generated line.
+    const predictedValues = [];
+    const generatedValues = [];
+    for (let i = 0; i < interval; i++) {
+      predictedValues.push(Math.round(100*((i + n) * slope + intercept)) / 100);
+    }
+    for (let i = 0; i < n; i++) {
+      generatedValues.push(Math.round(100*(i * slope + intercept)) / 100);
+    }
+    return [predictedIndices, predictedValues, generatedValues];
+  }
+
+  /**
+   * Generate error range lines using +/- standard error
+   * on intercept to original line.
+   * @param {Array} generatedValues
+   * @param {Array} values
+   * @param {Array} predictedValues
+   * @return {Array}
+   */
+  static getErrorData(generatedValues, values, predictedValues) {
+    const diffs = [];
+    for (let i = 0; i < generatedValues.length; i++) {
+      diffs.push(values[values.length - generatedValues.length + i] -
+          generatedValues[i]);
+    }
+    const sqDiffs = diffs.map((v) => v * v);
+    const stdDev = sqDiffs.reduce((sum, v) => sum + v) / values.length;
+    const maxValues = predictedValues.map(
+        (x) => Math.round(100 * (x + stdDev)) / 100);
+    const minValues = predictedValues.map(
+        (x) => Math.round(100 * (x - stdDev)) / 100);
+    return [maxValues, minValues];
+  }
+
+  /**
+   * Format all data using scattered dot representation for a single chart line.
+   * @param {Array} indices
+   * @param {Array} values
+   * @param {humber} dateRange
+   * @param {number} predRange
+   * @param {number} frequency
+   * @param {Date} endDate
+   * @return {Array}
+   */
+  static getAllData(indices, values, dateRange, predRange, frequency, endDate) {
+    // Set the number of data points that needs to be generated based on
+    // future time range and frequency.
+    let interval;
+    switch (predRange) {
+      case predRangeType.NEXT_MONTH:
+        interval = frequency === 1 ? 30 : 4;
+        break;
+      case predRangeType.NEXT_QUARTER:
+        interval = frequency === 1 ? 90 : 13;
+        break;
+      case predRangeType.NEXT_50:
+        interval = Math.floor((dateRange + 1) / (frequency * 2));
+        break;
+    }
+
+    const [predictedIndices, predictedValues, generatedValues] =
+      MrChart.getPredictedData(values, dateRange, interval, frequency, endDate);
+    const [maxValues, minValues] =
+      MrChart.getErrorData(generatedValues, values, predictedValues);
+    const n = generatedValues.length;
+
+    // Format data into an array of {x:"MM/DD/YYYY", y:1.00} to draw chart.
+    const originalData = [];
+    const predictedData = [];
+    const maxData = [{
+      x: indices[values.length - 1],
+      y: generatedValues[n - 1],
+    }];
+    const minData = [{
+      x: indices[values.length - 1],
+      y: generatedValues[n - 1],
+    }];
+    for (let i = 0; i < values.length; i++) {
+      originalData.push({x: indices[i], y: values[i]});
+    }
+    for (let i = 0; i < n; i++) {
+      predictedData.push({x: indices[values.length - n + i],
+        y: Math.max(Math.round(100 * generatedValues[i]) / 100, 0)});
+    }
+    for (let i = 0; i < predictedValues.length; i++) {
+      predictedData.push({
+        x: predictedIndices[i],
+        y: Math.max(predictedValues[i], 0),
+      });
+      maxData.push({x: predictedIndices[i], y: Math.max(maxValues[i], 0)});
+      minData.push({x: predictedIndices[i], y: Math.max(minValues[i], 0)});
+    }
+    return [originalData, predictedData, maxData, minData];
+  }
+
+  /**
+   * Sort lines by data in reversed chronological order and
+   * return top n lines with most issues.
+   * @param {Array} arrayValues
+   * @param {number} index
+   * @return {Array}
+   */
+  static getSortedLines(arrayValues, index) {
+    if (index >= arrayValues.length) {
+      return arrayValues;
+    }
+    // Convert data by reversing and starting from last digit and sort
+    // according to the resulting value. e.g. [4,2,0] => 24, [0,4,3] => 340
+    const sortedValues = arrayValues.slice().sort((arrX, arrY) => {
+      const intX = parseInt(
+          arrX.data.map((i) => i.toString()).reverse().join(''));
+      const intY = parseInt(
+          arrY.data.map((i) => i.toString()).reverse().join(''));
+      return intY - intX;
+    });
+    return sortedValues.slice(0, index);
+  }
+
+  /**
+   * Parses queryParams for groupBy property
+   * @param {Object<string, string>} queryParams
+   * @return {Object<string, string>}
+   */
+  static getGroupByFromQuery(queryParams) {
+    const defaultValue = {display: 'None', value: ''};
+
+    const labelMapping = {
+      'label': {
+        display: queryParams.labelprefix,
+        value: 'label',
+        labelPrefix: queryParams.labelprefix,
+      },
+    };
+
+    return groupByMapping[queryParams.groupby] ||
+        labelMapping[queryParams.groupby] ||
+        defaultValue;
+  }
+}
+
+customElements.define('mr-chart', MrChart);