blob: a4c4189dcfde3543e375afca5a1d500eb6e56165 [file] [log] [blame]
// 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);