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