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);