80374fe290cad5e5c0ca6371e817b16f77e39d5c
[WebKit-https.git] / Websites / perf.webkit.org / public / v3 / components / time-series-chart.js
1
2 class TimeSeriesChart extends ComponentBase {
3     constructor(sourceList, options)
4     {
5         super();
6         this._canvas = null;
7         this._sourceList = sourceList;
8         this._trendLines = null;
9         this._options = options;
10         this._fetchedTimeSeries = null;
11         this._sampledTimeSeriesData = null;
12         this._renderedTrendLines = false;
13         this._valueRangeCache = null;
14         this._annotations = null;
15         this._annotationRows = null;
16         this._startTime = null;
17         this._endTime = null;
18         this._width = null;
19         this._height = null;
20         this._contextScaleX = 1;
21         this._contextScaleY = 1;
22         this._rem = null;
23     }
24
25     _ensureCanvas()
26     {
27         if (!this._canvas) {
28             this._canvas = this._createCanvas();
29             this._canvas.style.display = 'block';
30             this._canvas.style.position = 'absolute';
31             this._canvas.style.left = '0px';
32             this._canvas.style.top = '0px';
33             this.content().appendChild(this._canvas);
34         }
35         return this._canvas;
36     }
37
38     static cssTemplate()
39     {
40         return `
41
42         :host {
43             display: block !important;
44             position: relative !important;
45         }
46
47         `;
48     }
49
50     static get enqueueToRenderOnResize() { return true; }
51
52     _createCanvas()
53     {
54         return document.createElement('canvas');
55     }
56
57     setDomain(startTime, endTime)
58     {
59         console.assert(startTime < endTime, 'startTime must be before endTime');
60         this._startTime = startTime;
61         this._endTime = endTime;
62         this._fetchedTimeSeries = null;
63         this.fetchMeasurementSets(false);
64     }
65
66     setSourceList(sourceList)
67     {
68         this._sourceList = sourceList;
69         this._fetchedTimeSeries = null;
70         this.fetchMeasurementSets(false);
71     }
72
73     sourceList() { return this._sourceList; }
74
75     clearTrendLines()
76     {
77         this._trendLines = null;
78         this._renderedTrendLines = false;
79         this.enqueueToRender();
80     }
81
82     setTrendLine(sourceIndex, trendLine)
83     {
84         if (this._trendLines)
85             this._trendLines = this._trendLines.slice(0);
86         else
87             this._trendLines = [];
88         this._trendLines[sourceIndex] = trendLine;
89         this._renderedTrendLines = false;
90         this.enqueueToRender();
91     }
92
93     fetchMeasurementSets(noCache)
94     {
95         var fetching = false;
96         for (var source of this._sourceList) {
97             if (source.measurementSet) {
98                 if (!noCache && source.measurementSet.hasFetchedRange(this._startTime, this._endTime))
99                     continue;
100                 source.measurementSet.fetchBetween(this._startTime, this._endTime, this._didFetchMeasurementSet.bind(this, source.measurementSet), noCache);
101                 fetching = true;
102             }
103
104         }
105         this._sampledTimeSeriesData = null;
106         this._valueRangeCache = null;
107         this._annotationRows = null;
108         if (!fetching)
109             this.enqueueToRender();
110     }
111
112     _didFetchMeasurementSet(set)
113     {
114         this._fetchedTimeSeries = null;
115         this._sampledTimeSeriesData = null;
116         this._valueRangeCache = null;
117         this._annotationRows = null;
118
119         this.enqueueToRender();
120     }
121
122     // FIXME: Figure out a way to make this readonly.
123     sampledTimeSeriesData(type)
124     {
125         if (!this._sampledTimeSeriesData)
126             return null;
127         for (var i = 0; i < this._sourceList.length; i++) {
128             if (this._sourceList[i].type == type)
129                 return this._sampledTimeSeriesData[i];
130         }
131         return null;
132     }
133
134     setAnnotations(annotations)
135     {
136         this._annotations = annotations;
137         this._annotationRows = null;
138
139         this.enqueueToRender();
140     }
141
142     render()
143     {
144         if (!this._startTime || !this._endTime)
145             return;
146
147         var canvas = this._ensureCanvas();
148
149         var metrics = this._layout();
150         if (!metrics.doneWork)
151             return;
152
153         Instrumentation.startMeasuringTime('TimeSeriesChart', 'render');
154
155         var context = canvas.getContext('2d');
156         context.scale(this._contextScaleX, this._contextScaleY);
157
158         context.clearRect(0, 0, this._width, this._height);
159
160         context.font = metrics.fontSize + 'px sans-serif';
161         const axis = this._options.axis;
162         if (axis) {
163             context.strokeStyle = axis.gridStyle;
164             context.lineWidth = 1 / this._contextScaleY;
165             this._renderXAxis(context, metrics, this._startTime, this._endTime);
166             this._renderYAxis(context, metrics, this._valueRangeCache[0], this._valueRangeCache[1]);
167         }
168
169         context.save();
170
171         context.beginPath();
172         context.rect(metrics.chartX, metrics.chartY, metrics.chartWidth, metrics.chartHeight);
173         context.clip();
174
175         this._renderChartContent(context, metrics);
176
177         context.restore();
178
179         context.setTransform(1, 0, 0, 1, 0, 0);
180
181         Instrumentation.endMeasuringTime('TimeSeriesChart', 'render');
182     }
183
184     _layout()
185     {
186         // FIXME: We should detect changes in _options and _sourceList.
187         // FIXME: We should consider proactively preparing time series caches to avoid jaggy scrolling.
188         var doneWork = this._updateCanvasSizeIfClientSizeChanged();
189         var metrics = this._computeHorizontalRenderingMetrics();
190         doneWork |= this._ensureSampledTimeSeries(metrics);
191         doneWork |= this._ensureTrendLines();
192         doneWork |= this._ensureValueRangeCache();
193         this._computeVerticalRenderingMetrics(metrics);
194         doneWork |= this._layoutAnnotationBars(metrics);
195         metrics.doneWork = doneWork;
196         return metrics;
197     }
198
199     _computeHorizontalRenderingMetrics()
200     {
201         // FIXME: We should detect when rem changes.
202         if (!this._rem)
203             this._rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
204
205         var timeDiff = this._endTime - this._startTime;
206         var startTime = this._startTime;
207
208         const axis = this._options.axis || {};
209         const fontSize = (axis.fontSize || 1) * this._rem;
210         const chartX = (axis.yAxisWidth || 0) * fontSize;
211         var chartY = 0;
212         var chartWidth = this._width - chartX;
213         var chartHeight = this._height - (axis.xAxisHeight || 0) * fontSize;
214
215         if (axis.xAxisEndPadding)
216             timeDiff += axis.xAxisEndPadding / (chartWidth / timeDiff);
217
218         return {
219             xToTime: function (x)
220             {
221                 var time = (x - chartX) / (chartWidth / timeDiff) + +startTime;
222                 console.assert(Math.abs(x - this.timeToX(time)) < 1e-6);
223                 return time;
224             },
225             timeToX: function (time) { return (chartWidth / timeDiff) * (time - startTime) + chartX; },
226             valueToY: function (value)
227             {
228                 return ((chartHeight - this.annotationHeight) / this.valueDiff) * (this.endValue - value) + chartY;
229             },
230             chartX: chartX,
231             chartY: chartY,
232             chartWidth: chartWidth,
233             chartHeight: chartHeight,
234             annotationHeight: 0, // Computed later in _layoutAnnotationBars.
235             fontSize: fontSize,
236             valueDiff: 0,
237             endValue: 0,
238         };
239     }
240
241     _computeVerticalRenderingMetrics(metrics)
242     {
243         var minValue = this._valueRangeCache[0];
244         var maxValue = this._valueRangeCache[1];
245         var valueDiff = maxValue - minValue;
246         var valueMargin = valueDiff * 0.05;
247         var endValue = maxValue + valueMargin;
248         var valueDiffWithMargin = valueDiff + valueMargin * 2;
249
250         metrics.valueDiff = valueDiffWithMargin;
251         metrics.endValue = endValue;
252     }
253
254     _layoutAnnotationBars(metrics)
255     {
256         if (!this._annotations || !this._options.annotations)
257             return false;
258
259         var barHeight = this._options.annotations.barHeight;
260         var barSpacing = this._options.annotations.barSpacing;
261
262         if (this._annotationRows) {
263             metrics.annotationHeight = this._annotationRows.length * (barHeight + barSpacing);
264             return false;
265         }
266
267         Instrumentation.startMeasuringTime('TimeSeriesChart', 'layoutAnnotationBars');
268
269         var minWidth = this._options.annotations.minWidth;
270
271         // (1) Expand the width of each bar to hit the minimum width and sort them by left edges.
272         this._annotations.forEach(function (annotation) {
273             var x1 = metrics.timeToX(annotation.startTime);
274             var x2 = metrics.timeToX(annotation.endTime);
275             if (x2 - x1 < minWidth) {
276                 x1 -= minWidth / 2;
277                 x2 += minWidth / 2;
278             }
279             annotation.x = x1;
280             annotation.width = x2 - x1;
281         });
282         var sortedAnnotations = this._annotations.sort(function (a, b) { return a.x - b.x });
283
284         // (2) For each bar, find the first row in which the last bar's right edge appear
285         // on the left of the bar as each row contains non-overlapping bars in the acending x order.
286         var rows = [];
287         sortedAnnotations.forEach(function (currentItem) {
288             for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) {
289                 var currentRow = rows[rowIndex];
290                 var lastItem = currentRow[currentRow.length - 1];
291                 if (lastItem.x + lastItem.width + minWidth < currentItem.x) {
292                     currentRow.push(currentItem);
293                     return;
294                 }
295             }
296             rows.push([currentItem]);
297         });
298
299         this._annotationRows = rows;
300         for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) {
301             for (var annotation of rows[rowIndex]) {
302                 annotation.y = metrics.chartY + metrics.chartHeight - (rows.length - rowIndex) * (barHeight + barSpacing);
303                 annotation.height = barHeight;
304             }
305         }
306
307         metrics.annotationHeight = rows.length * (barHeight + barSpacing);
308
309         Instrumentation.endMeasuringTime('TimeSeriesChart', 'layoutAnnotationBars');
310
311         return true;
312     }
313
314     _renderXAxis(context, metrics, startTime, endTime)
315     {
316         var typicalWidth = context.measureText('12/31 x').width;
317         var maxXAxisLabels = Math.floor(metrics.chartWidth / typicalWidth);
318         var xAxisGrid = TimeSeriesChart.computeTimeGrid(startTime, endTime, maxXAxisLabels);
319
320         for (var item of xAxisGrid) {
321             context.beginPath();
322             var x = metrics.timeToX(item.time);
323             context.moveTo(x, metrics.chartY);
324             context.lineTo(x, metrics.chartY + metrics.chartHeight);
325             context.stroke();
326         }
327
328         if (!this._options.axis.xAxisHeight)
329             return;
330
331         var rightEdgeOfPreviousItem = 0;
332         for (var item of xAxisGrid) {
333             var xCenter = metrics.timeToX(item.time);
334             var width = context.measureText(item.label).width;
335             var x = xCenter - width / 2;
336             if (x + width > metrics.chartX + metrics.chartWidth) {
337                 x = metrics.chartX + metrics.chartWidth - width;
338                 if (x <= rightEdgeOfPreviousItem)
339                     break;
340             }
341             rightEdgeOfPreviousItem = x + width;
342             context.fillText(item.label, x, metrics.chartY + metrics.chartHeight + metrics.fontSize);
343         }
344     }
345
346     _renderYAxis(context, metrics, minValue, maxValue)
347     {
348         const maxYAxisLabels = Math.floor(metrics.chartHeight / metrics.fontSize / 2);
349         const yAxisGrid = TimeSeriesChart.computeValueGrid(minValue, maxValue, maxYAxisLabels, this._options.axis.valueFormatter);
350
351         for (let item of yAxisGrid) {
352             context.beginPath();
353             const y = metrics.valueToY(item.value);
354             context.moveTo(metrics.chartX, y);
355             context.lineTo(metrics.chartX + metrics.chartWidth, y);
356             context.stroke();
357         }
358
359         if (!this._options.axis.yAxisWidth)
360             return;
361
362         for (let item of yAxisGrid) {
363             const x = (metrics.chartX - context.measureText(item.label).width) / 2;
364
365             let y = metrics.valueToY(item.value) + metrics.fontSize / 2.5;
366             if (y < metrics.fontSize)
367                 y = metrics.fontSize;
368
369             context.fillText(item.label, x, y);
370         }
371     }
372
373     _renderChartContent(context, metrics)
374     {
375         context.lineJoin = 'round';
376         for (var i = 0; i < this._sourceList.length; i++) {
377             var source = this._sourceList[i];
378             var series = this._sampledTimeSeriesData[i];
379             if (series)
380                 this._renderTimeSeries(context, metrics, source, series, this._trendLines && this._trendLines[i] ? 'background' : '');
381         }
382
383         for (var i = 0; i < this._sourceList.length; i++) {
384             var source = this._sourceList[i];
385             var trendLine = this._trendLines ? this._trendLines[i] : null;
386             if (series && trendLine)
387                 this._renderTimeSeries(context, metrics, source, trendLine, 'foreground');
388         }
389
390         if (!this._annotationRows)
391             return;
392
393         for (var row of this._annotationRows) {
394             for (var bar of row) {
395                 if (bar.x > this.chartWidth || bar.x + bar.width < 0)
396                     continue;
397                 context.fillStyle = bar.fillStyle;
398                 context.fillRect(bar.x, bar.y, bar.width, bar.height);
399             }
400         }
401     }
402
403     _renderTimeSeries(context, metrics, source, series, layerName)
404     {
405         for (var point of series) {
406             point.x = metrics.timeToX(point.time);
407             point.y = metrics.valueToY(point.value);
408         }
409
410         if (source.intervalStyle) {
411             context.strokeStyle = source.intervalStyle;
412             context.fillStyle = source.intervalStyle;
413             context.lineWidth = source.intervalWidth;
414
415             context.beginPath();
416             var width = 1;
417             for (var i = 0; i < series.length; i++) {
418                 var point = series[i];
419                 var interval = point.interval;
420                 var value = interval ? interval[0] : point.value;
421                 context.lineTo(point.x - width, metrics.valueToY(value));
422                 context.lineTo(point.x + width, metrics.valueToY(value));
423             }
424             for (var i = series.length - 1; i >= 0; i--) {
425                 var point = series[i];
426                 var interval = point.interval;
427                 var value = interval ? interval[1] : point.value;
428                 context.lineTo(point.x + width, metrics.valueToY(value));
429                 context.lineTo(point.x - width, metrics.valueToY(value));
430             }
431             context.fill();
432         }
433
434         context.strokeStyle = this._sourceOptionWithFallback(source, layerName + 'LineStyle', 'lineStyle');
435         context.lineWidth = this._sourceOptionWithFallback(source, layerName + 'LineWidth', 'lineWidth');
436         context.beginPath();
437         for (var point of series)
438             context.lineTo(point.x, point.y);
439         context.stroke();
440
441         context.fillStyle = this._sourceOptionWithFallback(source, layerName + 'PointStyle', 'pointStyle');
442         var radius = this._sourceOptionWithFallback(source, layerName + 'PointRadius', 'pointRadius');
443         if (radius) {
444             for (var point of series)
445                 this._fillCircle(context, point.x, point.y, radius);
446         }
447     }
448
449     _sourceOptionWithFallback(option, preferred, fallback)
450     {
451         return preferred in option ? option[preferred] : option[fallback];
452     }
453
454     _fillCircle(context, cx, cy, radius)
455     {
456         context.beginPath();
457         context.arc(cx, cy, radius, 0, 2 * Math.PI);
458         context.fill();
459     }
460
461     _ensureFetchedTimeSeries()
462     {
463         if (this._fetchedTimeSeries)
464             return false;
465
466         Instrumentation.startMeasuringTime('TimeSeriesChart', 'ensureFetchedTimeSeries');
467
468         this._fetchedTimeSeries = this._sourceList.map(function (source) {
469             return source.measurementSet.fetchedTimeSeries(source.type, source.includeOutliers, source.extendToFuture);
470         });
471
472         Instrumentation.endMeasuringTime('TimeSeriesChart', 'ensureFetchedTimeSeries');
473
474         return true;
475     }
476
477     _ensureSampledTimeSeries(metrics)
478     {
479         if (this._sampledTimeSeriesData)
480             return false;
481
482         this._ensureFetchedTimeSeries();
483
484         Instrumentation.startMeasuringTime('TimeSeriesChart', 'ensureSampledTimeSeries');
485
486         const startTime = this._startTime;
487         const endTime = this._endTime;
488         this._sampledTimeSeriesData = this._sourceList.map((source, sourceIndex) => {
489             const timeSeries = this._fetchedTimeSeries[sourceIndex];
490             if (!timeSeries)
491                 return null;
492
493             // A chart with X px width shouldn't have more than 2X / <radius-of-points> data points.
494             const maximumNumberOfPoints = 2 * metrics.chartWidth / (source.pointRadius || 2);
495
496             const pointAfterStart = timeSeries.findPointAfterTime(startTime);
497             const pointBeforeStart = (pointAfterStart ? timeSeries.previousPoint(pointAfterStart) : null) || timeSeries.firstPoint();
498             const pointAfterEnd = timeSeries.findPointAfterTime(endTime) || timeSeries.lastPoint();
499             if (!pointBeforeStart || !pointAfterEnd)
500                 return null;
501
502             // FIXME: Move this to TimeSeries.prototype.
503             const view = timeSeries.viewBetweenPoints(pointBeforeStart, pointAfterEnd);
504             if (!source.sampleData)
505                 return view;
506
507             return this._sampleTimeSeries(view, (endTime - startTime) / maximumNumberOfPoints, new Set);
508         });
509
510         Instrumentation.endMeasuringTime('TimeSeriesChart', 'ensureSampledTimeSeries');
511
512         this.dispatchAction('dataChange');
513
514         return true;
515     }
516
517     _sampleTimeSeries(view, minimumTimeDiff, excludedPoints)
518     {
519         if (view.length() < 2)
520             return view;
521
522         Instrumentation.startMeasuringTime('TimeSeriesChart', 'sampleTimeSeries');
523
524         const sampledData = view.filter((point, i) => {
525             if (excludedPoints.has(point.id))
526                 return true;
527             let previousPoint = view.previousPoint(point) || point;
528             let nextPoint = view.nextPoint(point) || point;
529             return nextPoint.time - previousPoint.time >= minimumTimeDiff;
530         });
531
532         Instrumentation.endMeasuringTime('TimeSeriesChart', 'sampleTimeSeries');
533
534         Instrumentation.reportMeasurement('TimeSeriesChart', 'samplingRatio', '%', sampledData.length() / view.length() * 100);
535
536         return sampledData;
537     }
538
539     _ensureTrendLines()
540     {
541         if (this._renderedTrendLines)
542             return false;
543         this._renderedTrendLines = true;
544         return true;
545     }
546
547     _ensureValueRangeCache()
548     {
549         if (this._valueRangeCache)
550             return false;
551
552         Instrumentation.startMeasuringTime('TimeSeriesChart', 'valueRangeCache');
553         var startTime = this._startTime;
554         var endTime = this._endTime;
555
556         var min;
557         var max;
558         for (var seriesData of this._sampledTimeSeriesData) {
559             if (!seriesData)
560                 continue;
561             for (var point of seriesData) {
562                 var minCandidate = point.interval ? point.interval[0] : point.value;
563                 var maxCandidate = point.interval ? point.interval[1] : point.value;
564                 min = (min === undefined) ? minCandidate : Math.min(min, minCandidate);
565                 max = (max === undefined) ? maxCandidate : Math.max(max, maxCandidate);
566             }
567         }
568         this._valueRangeCache = [min, max];
569         Instrumentation.endMeasuringTime('TimeSeriesChart', 'valueRangeCache');
570
571         return true;
572     }
573
574     _updateCanvasSizeIfClientSizeChanged()
575     {
576         var canvas = this._ensureCanvas();
577
578         var newWidth = this.element().clientWidth;
579         var newHeight = this.element().clientHeight;
580
581         if (!newWidth || !newHeight || (newWidth == this._width && newHeight == this._height))
582             return false;
583
584         var scale = window.devicePixelRatio;
585         canvas.width = newWidth * scale;
586         canvas.height = newHeight * scale;
587         canvas.style.width = newWidth + 'px';
588         canvas.style.height = newHeight + 'px';
589         this._contextScaleX = scale;
590         this._contextScaleY = scale;
591         this._width = newWidth;
592         this._height = newHeight;
593         this._sampledTimeSeriesData = null;
594         this._annotationRows = null;
595
596         return true;
597     }
598
599     static computeTimeGrid(min, max, maxLabels)
600     {
601         const diffPerLabel = (max - min) / maxLabels;
602
603         let iterator;
604         for (iterator of this._timeIterators()) {
605             if (iterator.diff >= diffPerLabel)
606                 break;
607         }
608         console.assert(iterator);
609
610         const currentTime = new Date(min);
611         currentTime.setUTCMilliseconds(0);
612         currentTime.setUTCSeconds(0);
613         currentTime.setUTCMinutes(0);
614         iterator.next(currentTime);
615
616         const fitsInOneDay = max - min < 24 * 3600 * 1000;
617
618         let result = [];
619
620         let previousDate = null;
621         let previousMonth = null;
622         while (currentTime <= max) {
623             const time = new Date(currentTime);
624             const month = time.getUTCMonth() + 1;
625             const date = time.getUTCDate();
626             const hour = time.getUTCHours();
627             const hourLabel = ((hour % 12) || 12) + (hour >= 12 ? 'PM' : 'AM');
628
629             iterator.next(currentTime);
630
631             let label;
632             const isMidnight = !hour;
633             if ((date == previousDate && month == previousMonth) || ((!isMidnight || previousDate == null) && fitsInOneDay))
634                 label = hourLabel;
635             else {
636                 label = `${month}/${date}`;
637                 if (!isMidnight && currentTime.getUTCDate() != date)
638                     label += ' ' + hourLabel;
639             }
640
641             result.push({time: time, label: label});
642
643             previousDate = date;
644             previousMonth = month;
645         }
646
647         console.assert(result.length <= maxLabels);
648
649         return result;
650     }
651
652     static _timeIterators()
653     {
654         var HOUR = 3600 * 1000;
655         var DAY = 24 * HOUR;
656         return [
657             {
658                 diff: 2 * HOUR,
659                 next: function (date) {
660                     if (date.getUTCHours() >= 22) {
661                         date.setUTCHours(0);
662                         date.setUTCDate(date.getUTCDate() + 1);
663                     } else
664                         date.setUTCHours(Math.floor(date.getUTCHours() / 2) * 2 + 2);
665                 },
666             },
667             {
668                 diff: 12 * HOUR,
669                 next: function (date) {
670                     if (date.getUTCHours() >= 12) {
671                         date.setUTCHours(0);
672                         date.setUTCDate(date.getUTCDate() + 1);
673                     } else
674                         date.setUTCHours(12);
675                 },
676             },
677             {
678                 diff: DAY,
679                 next: function (date) {
680                     date.setUTCHours(0);
681                     date.setUTCDate(date.getUTCDate() + 1);
682                 }
683             },
684             {
685                 diff: 2 * DAY,
686                 next: function (date) {
687                     date.setUTCHours(0);
688                     date.setUTCDate(date.getUTCDate() + 2);
689                 }
690             },
691             {
692                 diff: 7 * DAY,
693                 next: function (date) {
694                     date.setUTCHours(0);
695                     if (date.getUTCDay())
696                         date.setUTCDate(date.getUTCDate() + (7 - date.getUTCDay()));
697                     else
698                         date.setUTCDate(date.getUTCDate() + 7);
699                 }
700             },
701             {
702                 diff: 16 * DAY,
703                 next: function (date) {
704                     date.setUTCHours(0);
705                     if (date.getUTCDate() >= 15) {
706                         date.setUTCMonth(date.getUTCMonth() + 1);
707                         date.setUTCDate(1);
708                     } else
709                         date.setUTCDate(15);
710                 }
711             },
712             {
713                 diff: 31 * DAY,
714                 next: function (date) {
715                     date.setUTCHours(0);
716                     const dayOfMonth = date.getUTCDate();
717                     if (dayOfMonth > 1 && dayOfMonth < 15)
718                         date.setUTCDate(15);
719                     else {
720                         if (dayOfMonth != 15)
721                             date.setUTCDate(1);
722                         date.setUTCMonth(date.getUTCMonth() + 1);
723                     }
724                 }
725             },
726             {
727                 diff: 60 * DAY,
728                 next: function (date) {
729                     date.setUTCHours(0);
730                     date.setUTCDate(1);
731                     date.setUTCMonth(date.getUTCMonth() + 2);
732                 }
733             },
734             {
735                 diff: 90 * DAY,
736                 next: function (date) {
737                     date.setUTCHours(0);
738                     date.setUTCDate(1);
739                     date.setUTCMonth(date.getUTCMonth() + 3);
740                 }
741             },
742             {
743                 diff: 120 * DAY,
744                 next: function (date) {
745                     date.setUTCHours(0);
746                     date.setUTCDate(1);
747                     date.setUTCMonth(date.getUTCMonth() + 4);
748                 }
749             },
750         ];
751     }
752
753     static computeValueGrid(min, max, maxLabels, formatter)
754     {
755         const diff = max - min;
756         if (!diff)
757             return [];
758
759         const diffPerLabel = diff / maxLabels;
760
761         // First, reduce the diff between 1 and 1000 using a power of 1000 or 1024.
762         // FIXME: Share this code with Metric.makeFormatter.
763         const maxAbsValue = Math.max(Math.abs(min), Math.abs(max));
764         let scalingFactor = 1;
765         const divisor = formatter.divisor;
766         while (maxAbsValue * scalingFactor < 1)
767             scalingFactor *= formatter.divisor;
768         while (maxAbsValue * scalingFactor > divisor)
769             scalingFactor /= formatter.divisor;
770         const scaledDiff = diffPerLabel * scalingFactor;
771
772         // Second, compute the smallest number greater than the scaled diff
773         // which is a product of a power of 10, 2, and 5.
774         // These numbers are all factors of the decimal numeral system, 10.
775         const digitsInScaledDiff = Math.ceil(Math.log(scaledDiff) / Math.log(10));
776         let step = Math.pow(10, digitsInScaledDiff);
777         if (step / 5 >= scaledDiff)
778             step /= 5; // The most significant digit is 2
779         else if (step / 2 >= scaledDiff)
780             step /= 2 // The most significant digit is 5
781         step /= scalingFactor;
782
783         const gridValues = [];
784         let currentValue = Math.ceil(min / step) * step;
785         while (currentValue <= max) {
786             let unscaledValue = currentValue;
787             gridValues.push({value: unscaledValue, label: formatter(unscaledValue, maxAbsValue)});
788             currentValue += step;
789         }
790
791         return gridValues;
792     }
793 }
794
795 ComponentBase.defineElement('time-series-chart', TimeSeriesChart);