blob: a4c4189dcfde3543e375afca5a1d500eb6e56165 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6import qs from 'qs';
7import page from 'page';
8
9import {prpcClient} from 'prpc-client-instance.js';
10import {linearRegression} from 'shared/math.js';
11import './chops-chart.js';
12import {urlWithNewParams, createObjectComparisonFunc} from 'shared/helpers.js';
13
14const DEFAULT_NUM_DAYS = 90;
15const SECONDS_IN_DAY = 24 * 60 * 60;
16const MAX_QUERY_SIZE = 90;
17const MAX_DISPLAY_LINES = 10;
18const predRangeType = Object.freeze({
19 NEXT_MONTH: 0,
20 NEXT_QUARTER: 1,
21 NEXT_50: 2,
22 HIDE: 3,
23});
24const CHART_OPTIONS = {
25 animation: false,
26 responsive: true,
27 title: {
28 display: true,
29 text: 'Issues over time',
30 },
31 tooltips: {
32 mode: 'x',
33 intersect: false,
34 },
35 hover: {
36 mode: 'x',
37 intersect: false,
38 },
39 legend: {
40 display: true,
41 labels: {
42 boxWidth: 15,
43 },
44 },
45 scales: {
46 xAxes: [{
47 display: true,
48 type: 'time',
49 time: {parser: 'MM/DD/YYYY', tooltipFormat: 'll'},
50 scaleLabel: {
51 display: true,
52 labelString: 'Day',
53 },
54 }],
55 yAxes: [{
56 display: true,
57 ticks: {
58 beginAtZero: true,
59 },
60 scaleLabel: {
61 display: true,
62 labelString: 'Value',
63 },
64 }],
65 },
66};
67const COLOR_CHOICES = ['#00838F', '#B71C1C', '#2E7D32', '#00659C',
68 '#5D4037', '#558B2F', '#FF6F00', '#6A1B9A', '#880E4F', '#827717'];
69const BG_COLOR_CHOICES = ['#B2EBF2', '#EF9A9A', '#C8E6C9', '#B2DFDB',
70 '#D7CCC8', '#DCEDC8', '#FFECB3', '#E1BEE7', '#F8BBD0', '#E6EE9C'];
71
72/**
73 * Set of serialized state this element should update for.
74 * mr-app lowercases all query parameters before putting into store.
75 * @type {Set<string>}
76 */
77export const subscribedQuery = new Set([
78 'start-date',
79 'end-date',
80 'groupby',
81 'labelprefix',
82 'q',
83 'can',
84]);
85
86const queryParamsHaveChanged = createObjectComparisonFunc(subscribedQuery);
87
88/**
89 * Mapping between query param's groupby value and chart application data.
90 * @type {Object}
91 */
92const groupByMapping = {
93 'open': {display: 'Is open', value: 'open'},
94 'owner': {display: 'Owner', value: 'owner'},
95 'comonent': {display: 'Component', value: 'component'},
96 'status': {display: 'Status', value: 'status'},
97};
98
99/**
100 * `<mr-chart>`
101 *
102 * Component rendering the chart view
103 *
104 */
105export default class MrChart extends LitElement {
106 /** @override */
107 static get styles() {
108 return css`
109 :host {
110 display: block;
111 max-width: 800px;
112 margin: 0 auto;
113 }
114 chops-chart {
115 max-width: 100%;
116 }
117 div#options {
118 max-width: 720px;
119 margin: 2em auto;
120 text-align: center;
121 }
122 div#options #unsupported-fields {
123 font-weight: bold;
124 color: orange;
125 }
126 div.align {
127 display: flex;
128 }
129 div.align #frequency, div.align #groupBy {
130 display: inline-block;
131 width: 40%;
132 }
133 div.align #frequency #two-toggle {
134 font-size: 95%;
135 text-align: center;
136 margin-bottom: 5px;
137 }
138 div.align #time, div.align #prediction {
139 display: inline-block;
140 width: 60%;
141 }
142 #dropdown {
143 height: 50%;
144 }
145 div.section {
146 display: inline-block;
147 text-align: center;
148 }
149 div.section.input {
150 padding: 4px 10px;
151 }
152 .menu {
153 min-width: 50%;
154 text-align: left;
155 font-size: 12px;
156 box-sizing: border-box;
157 text-decoration: none;
158 white-space: nowrap;
159 padding: 0.25em 8px;
160 transition: 0.2s background ease-in-out;
161 cursor: pointer;
162 color: var(--chops-link-color);
163 }
164 .menu:hover {
165 background: hsl(0, 0%, 90%);
166 }
167 .choice.transparent {
168 background: var(--chops-white);
169 border-color: var(--chops-choice-color);
170 border-radius: 4px;
171 }
172 .choice.shown {
173 background: var(--chops-active-choice-bg);
174 }
175 .choice {
176 padding: 4px 10px;
177 background: var(--chops-choice-bg);
178 color: var(--chops-choice-color);
179 text-decoration: none;
180 display: inline-block;
181 }
182 .choice.checked {
183 background: var(--chops-active-choice-bg);
184 }
185 p .warning-message {
186 display: none;
187 font-size: 1.25em;
188 padding: 0.25em;
189 background-color: var(--chops-orange-50);
190 }
191 progress {
192 background-color: var(--chops-white);
193 border: 1px solid var(--chops-gray-500);
194 margin: 0 0 1em;
195 width: 100%;
196 visibility: visible;
197 }
198 ::-webkit-progress-bar {
199 background-color: var(--chops-white);
200 }
201 progress::-webkit-progress-value {
202 transition: width 1s;
203 background-color: #00838F;
204 }
205 `;
206 }
207
208 /** @override */
209 updated(changedProperties) {
210 if (changedProperties.has('queryParams')) {
211 this._setPropsFromQueryParams();
212 this._fetchData();
213 }
214 }
215
216 /** @override */
217 render() {
218 const doneLoading = this.progress === 1;
219 return html`
220 <chops-chart
221 type="line"
222 .options=${CHART_OPTIONS}
223 .data=${this._chartData(this.indices, this.values)}
224 ></chops-chart>
225 <div id="options">
226 <p id="unsupported-fields">
227 ${this.unsupportedFields.length ? `
228 Unsupported fields: ${this.unsupportedFields.join(', ')}`: ''}
229 </p>
230 <progress
231 value=${this.progress}
232 ?hidden=${doneLoading}
233 >Loading chart...</progress>
234 <p class="warning-message" ?hidden=${!this.searchLimitReached}>
235 Note: Some results are not being counted.
236 Please narrow your query.
237 </p>
238 <p class="warning-message" ?hidden=${!this.maxQuerySizeReached}>
239 Your query is too long.
240 Showing ${MAX_QUERY_SIZE} weeks from end date.
241 </p>
242 <p class="warning-message" ?hidden=${!this.dateRangeNotLegal}>
243 Your requested date range does not exist.
244 Showing ${MAX_QUERY_SIZE} days from end date.
245 </p>
246 <p class="warning-message" ?hidden=${!this.cannedQueryOpen}>
247 Your query scope prevents closed issues from showing.
248 </p>
249 <div class="align">
250 <div id="frequency">
251 <label for="two-toggle">Choose date range:</label>
252 <div id="two-toggle">
253 <chops-button @click="${this._setDateRange.bind(this, 180)}"
254 class="${this.dateRange === 180 ? 'choice checked': 'choice'}">
255 180 Days
256 </chops-button>
257 <chops-button @click="${this._setDateRange.bind(this, 90)}"
258 class="${this.dateRange === 90 ? 'choice checked': 'choice'}">
259 90 Days
260 </chops-button>
261 <chops-button @click="${this._setDateRange.bind(this, 30)}"
262 class="${this.dateRange === 30 ? 'choice checked': 'choice'}">
263 30 Days
264 </chops-button>
265 </div>
266 </div>
267 <div id="time">
268 <label for="start-date">Choose start and end date:</label>
269 <br />
270 <input
271 type="date"
272 id="start-date"
273 name="start-date"
274 .value=${this.startDate && this.startDate.toISOString().substr(0, 10)}
275 ?disabled=${!doneLoading}
276 @change=${(e) => this.startDate = MrChart.dateStringToDate(e.target.value)}
277 />
278 <input
279 type="date"
280 id="end-date"
281 name="end-date"
282 .value=${this.endDate && this.endDate.toISOString().substr(0, 10)}
283 ?disabled=${!doneLoading}
284 @change=${(e) => this.endDate = MrChart.dateStringToDate(e.target.value)}
285 />
286 <chops-button @click="${this._onDateChanged}" class=choice>
287 Apply
288 </chops-button>
289 </div>
290 </div>
291 <div class="align">
292 <div id="prediction">
293 <label for="two-toggle">Choose prediction range:</label>
294 <div id="two-toggle">
295 ${this._renderPredictChoice('Future Month', predRangeType.NEXT_MONTH)}
296 ${this._renderPredictChoice('Future Quarter', predRangeType.NEXT_QUARTER)}
297 ${this._renderPredictChoice('Future 50%', predRangeType.NEXT_50)}
298 ${this._renderPredictChoice('Hide', predRangeType.HIDE)}
299 </div>
300 </div>
301 <div id="groupBy">
302 <label for="dropdown">Choose group by:</label>
303 <mr-dropdown
304 id="dropdown"
305 ?disabled=${!doneLoading}
306 .text=${this.groupBy.display}
307 >
308 ${this.dropdownHTML}
309 </mr-dropdown>
310 </div>
311 </div>
312 </div>
313 `;
314 }
315
316 /**
317 * Renders a single prediction button.
318 * @param {string} choiceName The text displayed on the button.
319 * @param {number} rangeType An enum-like number specifying which range
320 * to use.
321 * @return {TemplateResult}
322 */
323 _renderPredictChoice(choiceName, rangeType) {
324 const changePrediction = (_e) => {
325 this.predRange = rangeType;
326 this._fetchData();
327 };
328 return html`
329 <chops-button
330 @click=${changePrediction}
331 class="${this.predRange === rangeType ? 'checked': ''} choice">
332 ${choiceName}
333 </chops-button>
334 `;
335 }
336
337 /** @override */
338 static get properties() {
339 return {
340 progress: {type: Number},
341 projectName: {type: String},
342 hotlistId: {type: Number},
343 indices: {type: Array},
344 values: {type: Array},
345 unsupportedFields: {type: Array},
346 dateRangeNotLegal: {type: Boolean},
347 dateRange: {type: Number},
348 frequency: {type: Number},
349 queryParams: {
350 type: Object,
351 hasChanged: queryParamsHaveChanged,
352 },
353 };
354 }
355
356 /** @override */
357 constructor() {
358 super();
359 this.progress = 0.05;
360 this.values = [];
361 this.indices = [];
362 this.unsupportedFields = [];
363 this.predRange = predRangeType.HIDE;
364 this._page = page;
365 }
366
367 /** @override */
368 connectedCallback() {
369 super.connectedCallback();
370
371 if (!this.projectName && !this.hotlistId) {
372 throw new Error('Attribute `projectName` or `hotlistId` required.');
373 }
374 this._setPropsFromQueryParams();
375 this._constructDropdownMenu();
376 }
377
378 /**
379 * Initialize queryParams and set properties from the queryParams.
380 * Since this page exists in both the SPA and ezt they initialize mr-chart
381 * differently, ie in ezt, this.queryParams will be undefined during
382 * connectedCallback. Until ezt is deleted, initialize props here.
383 */
384 _setPropsFromQueryParams() {
385 if (!this.queryParams) {
386 const params = qs.parse(document.location.search.substring(1));
387 // ezt pages used querystring as source of truth
388 // and 'labelPrefix'in query param, but SPA uses
389 // redux store's sitewide.queryParams as source of truth
390 // and lowercases all keys in sitewide.queryParams
391 if (params.hasOwnProperty('labelPrefix')) {
392 const labelPrefixValue = params['labelPrefix'];
393 params['labelprefix'] = labelPrefixValue;
394 delete params['labelPrefix'];
395 }
396 this.queryParams = params;
397 }
398 this.endDate = MrChart.getEndDate(this.queryParams['end-date']);
399 this.startDate = MrChart.getStartDate(
400 this.queryParams['start-date'],
401 this.endDate, DEFAULT_NUM_DAYS);
402 this.groupBy = MrChart.getGroupByFromQuery(this.queryParams);
403 }
404
405 /**
406 * Set dropdown options menu in HTML.
407 */
408 async _constructDropdownMenu() {
409 const response = await this._getLabelPrefixes();
410 let dropdownOptions = ['None', 'Component', 'Is open', 'Status', 'Owner'];
411 dropdownOptions = dropdownOptions.concat(response);
412 const dropdownHTML = dropdownOptions.map((str) => html`
413 <option class='menu' @click=${this._setGroupBy}>
414 ${str}</option>`);
415 this.dropdownHTML = html`${dropdownHTML}`;
416 }
417
418 /**
419 * Call global page.js to change frontend route based on new parameters
420 * @param {Object<string, string>} newParams
421 */
422 _changeUrlParams(newParams) {
423 const newUrl = urlWithNewParams(`/p/${this.projectName}/issues/list`,
424 this.queryParams, newParams);
425 this._page(newUrl);
426 }
427
428 /**
429 * Set start date and end date and trigger url action
430 */
431 _onDateChanged() {
432 const newParams = {
433 'start-date': this.startDate.toISOString().substr(0, 10),
434 'end-date': this.endDate.toISOString().substr(0, 10),
435 };
436 this._changeUrlParams(newParams);
437 }
438
439 /**
440 * Fetch data required to render chart
441 * @fires Event#allDataLoaded
442 */
443 async _fetchData() {
444 this.dateRange = Math.ceil(
445 (this.endDate - this.startDate) / (1000 * SECONDS_IN_DAY));
446
447 // Coordinate different params and flags, protect against illegal queries
448 // Case for start date greater than end date.
449 if (this.dateRange <= 0) {
450 this.frequency = 7;
451 this.dateRangeNotLegal = true;
452 this.maxQuerySizeReached = false;
453 this.dateRange = MAX_QUERY_SIZE;
454 } else {
455 this.dateRangeNotLegal = false;
456 if (this.dateRange >= MAX_QUERY_SIZE * 7) {
457 // Case for date range too long, requires >= MAX_QUERY_SIZE queries.
458 this.frequency = 7;
459 this.maxQuerySizeReached = true;
460 this.dateRange = MAX_QUERY_SIZE * 7;
461 } else {
462 this.maxQuerySizeReached = false;
463 if (this.dateRange < MAX_QUERY_SIZE) {
464 // Case for small date range, displayed in daily frequency.
465 this.frequency = 1;
466 } else {
467 // Case for medium date range, displayed in weekly frequency.
468 this.frequency = 7;
469 }
470 }
471 }
472 // Set canned query flag.
473 this.cannedQueryOpen = (this.queryParams.can === '2' &&
474 this.groupBy.value === 'open');
475
476 // Reset chart variables except indices.
477 this.progress = 0.05;
478
479 let numTimestampsLoaded = 0;
480 const timestampsChronological = MrChart.makeTimestamps(this.endDate,
481 this.frequency, this.dateRange);
482 const tsToIndexMap = new Map(timestampsChronological.map((ts, idx) => (
483 [ts, idx]
484 )));
485 this.indices = MrChart.makeIndices(timestampsChronological);
486 const timestamps = MrChart.sortInBisectOrder(timestampsChronological);
487 this.values = new Array(timestamps.length).fill(undefined);
488
489 const fetchPromises = timestamps.map(async (ts) => {
490 const data = await this._fetchDataAtTimestamp(ts);
491 const index = tsToIndexMap.get(ts);
492 this.values[index] = data.issues;
493 numTimestampsLoaded += 1;
494 const progressValue = numTimestampsLoaded / timestamps.length;
495 this.progress = progressValue;
496
497 return data;
498 });
499
500 const chartData = await Promise.all(fetchPromises);
501
502 // This is purely for testing purposes
503 this.dispatchEvent(new Event('allDataLoaded'));
504
505 // Check if the query includes any field values that are not supported.
506 const flatUnsupportedFields = chartData.reduce((acc, datum) => {
507 if (datum.unsupportedField) {
508 acc = acc.concat(datum.unsupportedField);
509 }
510 return acc;
511 }, []);
512 this.unsupportedFields = Array.from(new Set(flatUnsupportedFields));
513
514 this.searchLimitReached = chartData.some((d) => d.searchLimitReached);
515 }
516
517 /**
518 * fetch data at timestamp
519 * @param {number} timestamp
520 * @return {{date: number, issues: Array<Map.<string, number>>,
521 * unsupportedField: string, searchLimitReached: string}}
522 */
523 async _fetchDataAtTimestamp(timestamp) {
524 const query = this.queryParams.q;
525 const cannedQuery = this.queryParams.can;
526 const message = {
527 timestamp: timestamp,
528 projectName: this.projectName,
529 query: query,
530 cannedQuery: cannedQuery,
531 hotlistId: this.hotlistId,
532 groupBy: undefined,
533 };
534 if (this.groupBy.value !== '') {
535 message['groupBy'] = this.groupBy.value;
536 if (this.groupBy.value === 'label') {
537 message['labelPrefix'] = this.groupBy.labelPrefix;
538 }
539 }
540 const response = await prpcClient.call('monorail.Issues',
541 'IssueSnapshot', message);
542
543 let issues;
544 if (response.snapshotCount) {
545 issues = response.snapshotCount.reduce((map, curr) => {
546 if (curr.dimension !== undefined) {
547 if (this.groupBy.value === '') {
548 map.set('Issue Count', curr.count);
549 } else {
550 map.set(curr.dimension, curr.count);
551 }
552 }
553 return map;
554 }, new Map());
555 } else {
556 issues = new Map();
557 }
558 return {
559 date: timestamp * 1000,
560 issues: issues,
561 unsupportedField: response.unsupportedField,
562 searchLimitReached: response.searchLimitReached,
563 };
564 }
565
566 /**
567 * Get prefixes from the set of labels.
568 */
569 async _getLabelPrefixes() {
570 // If no project (i.e. viewing a hotlist), return empty list.
571 if (!this.projectName) {
572 return [];
573 }
574
575 const projectRequestMessage = {
576 project_name: this.projectName};
577 const labelsResponse = await prpcClient.call(
578 'monorail.Projects', 'GetLabelOptions', projectRequestMessage);
579 const labelPrefixes = new Set();
580 for (let i = 0; i < labelsResponse.labelOptions.length; i++) {
581 const label = labelsResponse.labelOptions[i].label;
582 if (label.includes('-')) {
583 labelPrefixes.add(label.split('-')[0]);
584 }
585 }
586 return Array.from(labelPrefixes);
587 }
588
589 /**
590 * construct chart data
591 * @param {Array} indices
592 * @param {Array} values
593 * @return {Object} chart data and options
594 */
595 _chartData(indices, values) {
596 // Generate a map of each data line {dimension:string, value:array}
597 const mapValues = new Map();
598 for (let i = 0; i < values.length; i++) {
599 if (values[i] !== undefined) {
600 values[i].forEach((value, key, map) => mapValues.set(key, []));
601 }
602 }
603 // Count the number of 0 or undefined data points.
604 let count = 0;
605 for (let i = 0; i < values.length; i++) {
606 if (values[i] !== undefined) {
607 if (values[i].size === 0) {
608 count++;
609 }
610 // Set none-existing data points 0.
611 mapValues.forEach((value, key, map) => {
612 mapValues.set(key, value.concat([values[i].get(key) || 0]));
613 });
614 } else {
615 count++;
616 }
617 }
618 // Legend display set back to default.
619 CHART_OPTIONS.legend.display = true;
620 // Check if any positive valued data exist, if not, draw an array of zeros.
621 if (count === values.length) {
622 return {
623 type: 'line',
624 labels: indices,
625 datasets: [{
626 label: this.groupBy.labelPrefix,
627 data: Array(indices.length).fill(0),
628 backgroundColor: COLOR_CHOICES[0],
629 borderColor: COLOR_CHOICES[0],
630 showLine: true,
631 fill: false,
632 }],
633 };
634 }
635 // Convert map to a dataset of lines.
636 let arrayValues = [];
637 mapValues.forEach((value, key, map) => {
638 arrayValues.push({
639 label: key,
640 data: value,
641 backgroundColor: COLOR_CHOICES[arrayValues.length %
642 COLOR_CHOICES.length],
643 borderColor: COLOR_CHOICES[arrayValues.length % COLOR_CHOICES.length],
644 showLine: true,
645 fill: false,
646 });
647 });
648 arrayValues = MrChart.getSortedLines(arrayValues, MAX_DISPLAY_LINES);
649 if (this.predRange === predRangeType.HIDE) {
650 return {
651 type: 'line',
652 labels: indices,
653 datasets: arrayValues,
654 };
655 }
656
657 let predictedValues = [];
658 let originalData;
659 let predictedData;
660 let maxData;
661 let minData;
662 let currColor;
663 let currBGColor;
664 // Check if displayed values > MAX_DISPLAY_LINES, hide legend.
665 if (arrayValues.length * 4 > MAX_DISPLAY_LINES) {
666 CHART_OPTIONS.legend.display = false;
667 } else {
668 CHART_OPTIONS.legend.display = true;
669 }
670 for (let i = 0; i < arrayValues.length; i++) {
671 [originalData, predictedData, maxData, minData] =
672 MrChart.getAllData(indices, arrayValues[i]['data'], this.dateRange,
673 this.predRange, this.frequency, this.endDate);
674 currColor = COLOR_CHOICES[i % COLOR_CHOICES.length];
675 currBGColor = BG_COLOR_CHOICES[i % COLOR_CHOICES.length];
676 predictedValues = predictedValues.concat([{
677 label: arrayValues[i]['label'],
678 backgroundColor: currColor,
679 borderColor: currColor,
680 data: originalData,
681 showLine: true,
682 fill: false,
683 }, {
684 label: arrayValues[i]['label'].concat(' prediction'),
685 backgroundColor: currColor,
686 borderColor: currColor,
687 borderDash: [5, 5],
688 data: predictedData,
689 pointRadius: 0,
690 showLine: true,
691 fill: false,
692 }, {
693 label: arrayValues[i]['label'].concat(' lower error'),
694 backgroundColor: currBGColor,
695 borderColor: currBGColor,
696 borderDash: [5, 5],
697 data: minData,
698 pointRadius: 0,
699 showLine: true,
700 hidden: true,
701 fill: false,
702 }, {
703 label: arrayValues[i]['label'].concat(' upper error'),
704 backgroundColor: currBGColor,
705 borderColor: currBGColor,
706 borderDash: [5, 5],
707 data: maxData,
708 pointRadius: 0,
709 showLine: true,
710 hidden: true,
711 fill: '-1',
712 }]);
713 }
714 return {
715 type: 'scatter',
716 datasets: predictedValues,
717 };
718 }
719
720 /**
721 * Change group by based on dropdown menu selection.
722 * @param {Event} e
723 */
724 _setGroupBy(e) {
725 switch (e.target.text) {
726 case 'None':
727 this.groupBy = {value: undefined};
728 break;
729 case 'Is open':
730 this.groupBy = {value: 'open'};
731 break;
732 case 'Owner':
733 case 'Component':
734 case 'Status':
735 this.groupBy = {value: e.target.text.toLowerCase()};
736 break;
737 default:
738 this.groupBy = {value: 'label', labelPrefix: e.target.text};
739 }
740 this.groupBy['display'] = e.target.text;
741 this.shadowRoot.querySelector('#dropdown').text = e.target.text;
742 this.shadowRoot.querySelector('#dropdown').close();
743
744 const newParams = {
745 'groupby': this.groupBy.value,
746 'labelprefix': this.groupBy.labelPrefix,
747 };
748
749 this._changeUrlParams(newParams);
750 }
751
752 /**
753 * Change date range and frequency based on button clicked.
754 * @param {number} dateRange Number of days in date range
755 */
756 _setDateRange(dateRange) {
757 if (this.dateRange !== dateRange) {
758 this.startDate = new Date(
759 this.endDate.getTime() - 1000 * SECONDS_IN_DAY * dateRange);
760 this._onDateChanged();
761 window.getTSMonClient().recordDateRangeChange(dateRange);
762 }
763 }
764
765 /**
766 * Move first, last, and median to the beginning of the array, recursively.
767 * @param {Array} timestamps
768 * @return {Array}
769 */
770 static sortInBisectOrder(timestamps) {
771 const arr = [];
772 if (timestamps.length === 0) {
773 return arr;
774 } else if (timestamps.length <= 2) {
775 return timestamps;
776 } else {
777 const beginTs = timestamps.shift();
778 const endTs = timestamps.pop();
779 const medianTs = timestamps.splice(timestamps.length / 2, 1)[0];
780 return [beginTs, endTs, medianTs].concat(
781 MrChart.sortInBisectOrder(timestamps));
782 }
783 }
784
785 /**
786 * Populate array of timestamps we want to fetch.
787 * @param {Date} endDate
788 * @param {number} frequency
789 * @param {number} numDays
790 * @return {Array}
791 */
792 static makeTimestamps(endDate, frequency, numDays=DEFAULT_NUM_DAYS) {
793 if (!endDate) {
794 throw new Error('endDate required');
795 }
796 const endTimeSeconds = Math.round(endDate.getTime() / 1000);
797 const timestampsChronological = [];
798 for (let i = 0; i < numDays; i += frequency) {
799 timestampsChronological.unshift(endTimeSeconds - (SECONDS_IN_DAY * i));
800 }
801 return timestampsChronological;
802 }
803
804 /**
805 * Convert a string '2018-11-03' to a Date object.
806 * @param {string} dateString
807 * @return {Date}
808 */
809 static dateStringToDate(dateString) {
810 if (!dateString) {
811 return null;
812 }
813 const splitDate = dateString.split('-');
814 const year = Number.parseInt(splitDate[0]);
815 // Month is 0-indexed, so subtract one.
816 const month = Number.parseInt(splitDate[1]) - 1;
817 const day = Number.parseInt(splitDate[2]);
818 return new Date(Date.UTC(year, month, day, 23, 59, 59));
819 }
820
821 /**
822 * Returns a Date parsed from string input, defaults to current date.
823 * @param {string} input
824 * @return {Date}
825 */
826 static getEndDate(input) {
827 if (input) {
828 const date = MrChart.dateStringToDate(input);
829 if (date) {
830 return date;
831 }
832 }
833 const today = new Date();
834 today.setHours(23);
835 today.setMinutes(59);
836 today.setSeconds(59);
837 return today;
838 }
839
840 /**
841 * Return a Date parsed from string input
842 * defaults to diff days befores endDate
843 * @param {string} input
844 * @param {Date} endDate
845 * @param {number} diff
846 * @return {Date}
847 */
848 static getStartDate(input, endDate, diff) {
849 if (input) {
850 const date = MrChart.dateStringToDate(input);
851 if (date) {
852 return date;
853 }
854 }
855 return new Date(endDate.getTime() - 1000 * SECONDS_IN_DAY * diff);
856 }
857
858 /**
859 * Make indices
860 * @param {Array} timestamps
861 * @return {Array}
862 */
863 static makeIndices(timestamps) {
864 const dateFormat = {year: 'numeric', month: 'numeric', day: 'numeric'};
865 return timestamps.map((ts) => (
866 (new Date(ts * 1000)).toLocaleDateString('en-US', dateFormat)
867 ));
868 }
869
870 /**
871 * Generate predicted future data based on previous data.
872 * @param {Array} values
873 * @param {number} dateRange
874 * @param {number} interval
875 * @param {number} frequency
876 * @param {Date} inputEndDate
877 * @return {Array}
878 */
879 static getPredictedData(
880 values, dateRange, interval, frequency, inputEndDate) {
881 // TODO(weihanl): changes to support frequencies other than 1 and 7.
882 let n;
883 let endDateRange;
884 if (frequency === 1) {
885 // Display in daily.
886 n = values.length;
887 endDateRange = interval;
888 } else {
889 // Display in weekly.
890 n = Math.floor((DEFAULT_NUM_DAYS + 1) / 7);
891 endDateRange = interval * 7 - 1;
892 }
893 const [slope, intercept] = linearRegression(values, n);
894 const endDate = new Date(inputEndDate.getTime() +
895 1000 * SECONDS_IN_DAY * (1 + endDateRange));
896 const timestampsChronological = MrChart.makeTimestamps(
897 endDate, frequency, endDateRange);
898 const predictedIndices = MrChart.makeIndices(timestampsChronological);
899
900 // Obtain future data and past data on the generated line.
901 const predictedValues = [];
902 const generatedValues = [];
903 for (let i = 0; i < interval; i++) {
904 predictedValues.push(Math.round(100*((i + n) * slope + intercept)) / 100);
905 }
906 for (let i = 0; i < n; i++) {
907 generatedValues.push(Math.round(100*(i * slope + intercept)) / 100);
908 }
909 return [predictedIndices, predictedValues, generatedValues];
910 }
911
912 /**
913 * Generate error range lines using +/- standard error
914 * on intercept to original line.
915 * @param {Array} generatedValues
916 * @param {Array} values
917 * @param {Array} predictedValues
918 * @return {Array}
919 */
920 static getErrorData(generatedValues, values, predictedValues) {
921 const diffs = [];
922 for (let i = 0; i < generatedValues.length; i++) {
923 diffs.push(values[values.length - generatedValues.length + i] -
924 generatedValues[i]);
925 }
926 const sqDiffs = diffs.map((v) => v * v);
927 const stdDev = sqDiffs.reduce((sum, v) => sum + v) / values.length;
928 const maxValues = predictedValues.map(
929 (x) => Math.round(100 * (x + stdDev)) / 100);
930 const minValues = predictedValues.map(
931 (x) => Math.round(100 * (x - stdDev)) / 100);
932 return [maxValues, minValues];
933 }
934
935 /**
936 * Format all data using scattered dot representation for a single chart line.
937 * @param {Array} indices
938 * @param {Array} values
939 * @param {humber} dateRange
940 * @param {number} predRange
941 * @param {number} frequency
942 * @param {Date} endDate
943 * @return {Array}
944 */
945 static getAllData(indices, values, dateRange, predRange, frequency, endDate) {
946 // Set the number of data points that needs to be generated based on
947 // future time range and frequency.
948 let interval;
949 switch (predRange) {
950 case predRangeType.NEXT_MONTH:
951 interval = frequency === 1 ? 30 : 4;
952 break;
953 case predRangeType.NEXT_QUARTER:
954 interval = frequency === 1 ? 90 : 13;
955 break;
956 case predRangeType.NEXT_50:
957 interval = Math.floor((dateRange + 1) / (frequency * 2));
958 break;
959 }
960
961 const [predictedIndices, predictedValues, generatedValues] =
962 MrChart.getPredictedData(values, dateRange, interval, frequency, endDate);
963 const [maxValues, minValues] =
964 MrChart.getErrorData(generatedValues, values, predictedValues);
965 const n = generatedValues.length;
966
967 // Format data into an array of {x:"MM/DD/YYYY", y:1.00} to draw chart.
968 const originalData = [];
969 const predictedData = [];
970 const maxData = [{
971 x: indices[values.length - 1],
972 y: generatedValues[n - 1],
973 }];
974 const minData = [{
975 x: indices[values.length - 1],
976 y: generatedValues[n - 1],
977 }];
978 for (let i = 0; i < values.length; i++) {
979 originalData.push({x: indices[i], y: values[i]});
980 }
981 for (let i = 0; i < n; i++) {
982 predictedData.push({x: indices[values.length - n + i],
983 y: Math.max(Math.round(100 * generatedValues[i]) / 100, 0)});
984 }
985 for (let i = 0; i < predictedValues.length; i++) {
986 predictedData.push({
987 x: predictedIndices[i],
988 y: Math.max(predictedValues[i], 0),
989 });
990 maxData.push({x: predictedIndices[i], y: Math.max(maxValues[i], 0)});
991 minData.push({x: predictedIndices[i], y: Math.max(minValues[i], 0)});
992 }
993 return [originalData, predictedData, maxData, minData];
994 }
995
996 /**
997 * Sort lines by data in reversed chronological order and
998 * return top n lines with most issues.
999 * @param {Array} arrayValues
1000 * @param {number} index
1001 * @return {Array}
1002 */
1003 static getSortedLines(arrayValues, index) {
1004 if (index >= arrayValues.length) {
1005 return arrayValues;
1006 }
1007 // Convert data by reversing and starting from last digit and sort
1008 // according to the resulting value. e.g. [4,2,0] => 24, [0,4,3] => 340
1009 const sortedValues = arrayValues.slice().sort((arrX, arrY) => {
1010 const intX = parseInt(
1011 arrX.data.map((i) => i.toString()).reverse().join(''));
1012 const intY = parseInt(
1013 arrY.data.map((i) => i.toString()).reverse().join(''));
1014 return intY - intX;
1015 });
1016 return sortedValues.slice(0, index);
1017 }
1018
1019 /**
1020 * Parses queryParams for groupBy property
1021 * @param {Object<string, string>} queryParams
1022 * @return {Object<string, string>}
1023 */
1024 static getGroupByFromQuery(queryParams) {
1025 const defaultValue = {display: 'None', value: ''};
1026
1027 const labelMapping = {
1028 'label': {
1029 display: queryParams.labelprefix,
1030 value: 'label',
1031 labelPrefix: queryParams.labelprefix,
1032 },
1033 };
1034
1035 return groupByMapping[queryParams.groupby] ||
1036 labelMapping[queryParams.groupby] ||
1037 defaultValue;
1038 }
1039}
1040
1041customElements.define('mr-chart', MrChart);