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