New sampling algorithm shows very few points when zoomed out
[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             const pointAfterStart = timeSeries.findPointAfterTime(startTime);
494             const pointBeforeStart = (pointAfterStart ? timeSeries.previousPoint(pointAfterStart) : null) || timeSeries.firstPoint();
495             const pointAfterEnd = timeSeries.findPointAfterTime(endTime) || timeSeries.lastPoint();
496             if (!pointBeforeStart || !pointAfterEnd)
497                 return null;
498
499             // FIXME: Move this to TimeSeries.prototype.
500             const view = timeSeries.viewBetweenPoints(pointBeforeStart, pointAfterEnd);
501             if (!source.sampleData)
502                 return view;
503
504             // A chart with X px width shouldn't have more than 2X / <radius-of-points> data points.
505             const viewWidth = Math.min(metrics.chartWidth, metrics.timeToX(pointAfterEnd.time) - metrics.timeToX(pointBeforeStart.time));
506             const maximumNumberOfPoints = 2 * viewWidth / (source.pointRadius || 2);
507
508             return this._sampleTimeSeries(view, maximumNumberOfPoints, new Set);
509         });
510
511         Instrumentation.endMeasuringTime('TimeSeriesChart', 'ensureSampledTimeSeries');
512
513         this.dispatchAction('dataChange');
514
515         return true;
516     }
517
518     _sampleTimeSeries(view, maximumNumberOfPoints, excludedPoints)
519     {
520
521         if (view.length() < 2 || maximumNumberOfPoints >= view.length() || maximumNumberOfPoints < 1)
522             return view;
523
524         Instrumentation.startMeasuringTime('TimeSeriesChart', 'sampleTimeSeries');
525
526         let ranks = new Array(view.length());
527         let i = 0;
528         for (let point of view) {
529             let previousPoint = view.previousPoint(point) || point;
530             let nextPoint = view.nextPoint(point) || point;
531             ranks[i] = nextPoint.time - previousPoint.time;
532             i++;
533         }
534
535         const sortedRanks = ranks.slice(0).sort((a, b) => b - a);
536         const minimumRank = sortedRanks[Math.floor(maximumNumberOfPoints)];
537         const sampledData = view.filter((point, i) => {
538             return excludedPoints.has(point.id) || ranks[i] >= minimumRank;
539         });
540
541         Instrumentation.endMeasuringTime('TimeSeriesChart', 'sampleTimeSeries');
542
543         Instrumentation.reportMeasurement('TimeSeriesChart', 'samplingRatio', '%', sampledData.length() / view.length() * 100);
544
545         return sampledData;
546     }
547
548     _ensureTrendLines()
549     {
550         if (this._renderedTrendLines)
551             return false;
552         this._renderedTrendLines = true;
553         return true;
554     }
555
556     _ensureValueRangeCache()
557     {
558         if (this._valueRangeCache)
559             return false;
560
561         Instrumentation.startMeasuringTime('TimeSeriesChart', 'valueRangeCache');
562         var startTime = this._startTime;
563         var endTime = this._endTime;
564
565         var min;
566         var max;
567         for (var seriesData of this._sampledTimeSeriesData) {
568             if (!seriesData)
569                 continue;
570             for (var point of seriesData) {
571                 var minCandidate = point.interval ? point.interval[0] : point.value;
572                 var maxCandidate = point.interval ? point.interval[1] : point.value;
573                 min = (min === undefined) ? minCandidate : Math.min(min, minCandidate);
574                 max = (max === undefined) ? maxCandidate : Math.max(max, maxCandidate);
575             }
576         }
577         this._valueRangeCache = [min, max];
578         Instrumentation.endMeasuringTime('TimeSeriesChart', 'valueRangeCache');
579
580         return true;
581     }
582
583     _updateCanvasSizeIfClientSizeChanged()
584     {
585         var canvas = this._ensureCanvas();
586
587         var newWidth = this.element().clientWidth;
588         var newHeight = this.element().clientHeight;
589
590         if (!newWidth || !newHeight || (newWidth == this._width && newHeight == this._height))
591             return false;
592
593         var scale = window.devicePixelRatio;
594         canvas.width = newWidth * scale;
595         canvas.height = newHeight * scale;
596         canvas.style.width = newWidth + 'px';
597         canvas.style.height = newHeight + 'px';
598         this._contextScaleX = scale;
599         this._contextScaleY = scale;
600         this._width = newWidth;
601         this._height = newHeight;
602         this._sampledTimeSeriesData = null;
603         this._annotationRows = null;
604
605         return true;
606     }
607
608     static computeTimeGrid(min, max, maxLabels)
609     {
610         const diffPerLabel = (max - min) / maxLabels;
611
612         let iterator;
613         for (iterator of this._timeIterators()) {
614             if (iterator.diff >= diffPerLabel)
615                 break;
616         }
617         console.assert(iterator);
618
619         const currentTime = new Date(min);
620         currentTime.setUTCMilliseconds(0);
621         currentTime.setUTCSeconds(0);
622         currentTime.setUTCMinutes(0);
623         iterator.next(currentTime);
624
625         const fitsInOneDay = max - min < 24 * 3600 * 1000;
626
627         let result = [];
628
629         let previousDate = null;
630         let previousMonth = null;
631         while (currentTime <= max && result.length < maxLabels) {
632             const time = new Date(currentTime);
633             const month = time.getUTCMonth() + 1;
634             const date = time.getUTCDate();
635             const hour = time.getUTCHours();
636             const hourLabel = ((hour % 12) || 12) + (hour >= 12 ? 'PM' : 'AM');
637
638             iterator.next(currentTime);
639
640             let label;
641             const isMidnight = !hour;
642             if ((date == previousDate && month == previousMonth) || ((!isMidnight || previousDate == null) && fitsInOneDay))
643                 label = hourLabel;
644             else {
645                 label = `${month}/${date}`;
646                 if (!isMidnight && currentTime.getUTCDate() != date)
647                     label += ' ' + hourLabel;
648             }
649
650             result.push({time: time, label: label});
651
652             previousDate = date;
653             previousMonth = month;
654         }
655
656         console.assert(result.length <= maxLabels);
657
658         return result;
659     }
660
661     static _timeIterators()
662     {
663         var HOUR = 3600 * 1000;
664         var DAY = 24 * HOUR;
665         return [
666             {
667                 diff: 2 * HOUR,
668                 next: function (date) {
669                     if (date.getUTCHours() >= 22) {
670                         date.setUTCHours(0);
671                         date.setUTCDate(date.getUTCDate() + 1);
672                     } else
673                         date.setUTCHours(Math.floor(date.getUTCHours() / 2) * 2 + 2);
674                 },
675             },
676             {
677                 diff: 12 * HOUR,
678                 next: function (date) {
679                     if (date.getUTCHours() >= 12) {
680                         date.setUTCHours(0);
681                         date.setUTCDate(date.getUTCDate() + 1);
682                     } else
683                         date.setUTCHours(12);
684                 },
685             },
686             {
687                 diff: DAY,
688                 next: function (date) {
689                     date.setUTCHours(0);
690                     date.setUTCDate(date.getUTCDate() + 1);
691                 }
692             },
693             {
694                 diff: 2 * DAY,
695                 next: function (date) {
696                     date.setUTCHours(0);
697                     date.setUTCDate(date.getUTCDate() + 2);
698                 }
699             },
700             {
701                 diff: 7 * DAY,
702                 next: function (date) {
703                     date.setUTCHours(0);
704                     if (date.getUTCDay())
705                         date.setUTCDate(date.getUTCDate() + (7 - date.getUTCDay()));
706                     else
707                         date.setUTCDate(date.getUTCDate() + 7);
708                 }
709             },
710             {
711                 diff: 16 * DAY,
712                 next: function (date) {
713                     date.setUTCHours(0);
714                     if (date.getUTCDate() >= 15) {
715                         date.setUTCMonth(date.getUTCMonth() + 1);
716                         date.setUTCDate(1);
717                     } else
718                         date.setUTCDate(15);
719                 }
720             },
721             {
722                 diff: 31 * DAY,
723                 next: function (date) {
724                     date.setUTCHours(0);
725                     const dayOfMonth = date.getUTCDate();
726                     if (dayOfMonth > 1 && dayOfMonth < 15)
727                         date.setUTCDate(15);
728                     else {
729                         if (dayOfMonth != 15)
730                             date.setUTCDate(1);
731                         date.setUTCMonth(date.getUTCMonth() + 1);
732                     }
733                 }
734             },
735             {
736                 diff: 60 * DAY,
737                 next: function (date) {
738                     date.setUTCHours(0);
739                     date.setUTCDate(1);
740                     date.setUTCMonth(date.getUTCMonth() + 2);
741                 }
742             },
743             {
744                 diff: 90 * DAY,
745                 next: function (date) {
746                     date.setUTCHours(0);
747                     date.setUTCDate(1);
748                     date.setUTCMonth(date.getUTCMonth() + 3);
749                 }
750             },
751             {
752                 diff: 120 * DAY,
753                 next: function (date) {
754                     date.setUTCHours(0);
755                     date.setUTCDate(1);
756                     date.setUTCMonth(date.getUTCMonth() + 4);
757                 }
758             },
759         ];
760     }
761
762     static computeValueGrid(min, max, maxLabels, formatter)
763     {
764         const diff = max - min;
765         if (!diff)
766             return [];
767
768         const diffPerLabel = diff / maxLabels;
769
770         // First, reduce the diff between 1 and 1000 using a power of 1000 or 1024.
771         // FIXME: Share this code with Metric.makeFormatter.
772         const maxAbsValue = Math.max(Math.abs(min), Math.abs(max));
773         let scalingFactor = 1;
774         const divisor = formatter.divisor;
775         while (maxAbsValue * scalingFactor < 1)
776             scalingFactor *= formatter.divisor;
777         while (maxAbsValue * scalingFactor > divisor)
778             scalingFactor /= formatter.divisor;
779         const scaledDiff = diffPerLabel * scalingFactor;
780
781         // Second, compute the smallest number greater than the scaled diff
782         // which is a product of a power of 10, 2, and 5.
783         // These numbers are all factors of the decimal numeral system, 10.
784         const digitsInScaledDiff = Math.ceil(Math.log(scaledDiff) / Math.log(10));
785         let step = Math.pow(10, digitsInScaledDiff);
786         if (step / 5 >= scaledDiff)
787             step /= 5; // The most significant digit is 2
788         else if (step / 2 >= scaledDiff)
789             step /= 2 // The most significant digit is 5
790         step /= scalingFactor;
791
792         const gridValues = [];
793         let currentValue = Math.ceil(min / step) * step;
794         while (currentValue <= max) {
795             let unscaledValue = currentValue;
796             gridValues.push({value: unscaledValue, label: formatter(unscaledValue, maxAbsValue)});
797             currentValue += step;
798         }
799
800         return gridValues;
801     }
802 }
803
804 ComponentBase.defineElement('time-series-chart', TimeSeriesChart);