REGRESSION(r212853): Comparisons to baseline no longer shows up
[WebKit-https.git] / Websites / perf.webkit.org / public / v3 / components / interactive-time-series-chart.js
1
2 class InteractiveTimeSeriesChart extends TimeSeriesChart {
3     constructor(sourceList, options)
4     {
5         super(sourceList, options);
6         this._indicatorID = null;
7         this._indicatorIsLocked = false;
8         this._currentAnnotation = null;
9         this._forceRender = false;
10         this._lastMouseDownLocation = null;
11         this._dragStarted = false;
12         this._didEndDrag = false;
13         this._selectionTimeRange = null;
14         this._renderedSelection = null;
15         this._annotationLabel = null;
16         this._renderedAnnotation = null;
17     }
18
19     currentIndicator()
20     {
21         var id = this._indicatorID;
22         if (!id)
23             return null;
24
25         if (!this._sampledTimeSeriesData)
26             return null;
27
28         for (var view of this._sampledTimeSeriesData) {
29             if (!view)
30                 continue;
31             let point = view.findById(id);
32             if (!point)
33                 continue;
34             return {view, point, isLocked: this._indicatorIsLocked};
35         }
36         return null;
37     }
38
39     currentSelection() { return this._selectionTimeRange; }
40
41     selectedPoints(type)
42     {
43         const selection = this._selectionTimeRange;
44         const data = this.sampledTimeSeriesData(type);
45         return selection && data ? data.viewTimeRange(selection[0], selection[1]) : null;
46     }
47
48     firstSelectedPoint(type)
49     {
50         const selection = this._selectionTimeRange;
51         const data = this.sampledTimeSeriesData(type);
52         return selection && data ? data.firstPointInTimeRange(selection[0], selection[1]) : null;
53     }
54
55     setIndicator(id, shouldLock)
56     {
57         var selectionDidChange = !!this._sampledTimeSeriesData;
58
59         this._indicatorID = id;
60         this._indicatorIsLocked = shouldLock;
61
62         this._lastMouseDownLocation = null;
63         this._selectionTimeRange = null;
64         this._forceRender = true;
65
66         if (selectionDidChange)
67             this._notifySelectionChanged(false);
68     }
69
70     moveLockedIndicatorWithNotification(forward)
71     {
72         const indicator = this.currentIndicator();
73         if (!indicator || !indicator.isLocked)
74             return false;
75         console.assert(!this._selectionTimeRange);
76
77         const newPoint = forward ? indicator.view.nextPoint(indicator.point) : indicator.view.previousPoint(indicator.point);
78         if (!newPoint)
79             return false;
80
81         this._indicatorID = newPoint.id;
82         this._lastMouseDownLocation = null;
83         this._forceRender = true;
84
85         this._notifyIndicatorChanged();
86     }
87
88     setSelection(newSelectionTimeRange)
89     {
90         var indicatorDidChange = !!this._indicatorID;
91         this._indicatorID = null;
92         this._indicatorIsLocked = false;
93
94         this._lastMouseDownLocation = null;
95         this._selectionTimeRange = newSelectionTimeRange;
96         this._forceRender = true;
97
98         if (indicatorDidChange)
99             this._notifyIndicatorChanged();
100     }
101
102     _createCanvas()
103     {
104         var canvas = super._createCanvas();
105         canvas.addEventListener('mousemove', this._mouseMove.bind(this));
106         canvas.addEventListener('mouseleave', this._mouseLeave.bind(this));
107         canvas.addEventListener('mousedown', this._mouseDown.bind(this));
108         window.addEventListener('mouseup', this._mouseUp.bind(this));
109         canvas.addEventListener('click', this._click.bind(this));
110
111         this._annotationLabel = this.content('annotation-label');
112         this._zoomButton = this.content('zoom-button');
113
114         this._zoomButton.onclick = (event) => {
115             event.preventDefault();
116             this.dispatchAction('zoom', this._selectionTimeRange);
117         }
118
119         return canvas;
120     }
121
122     static htmlTemplate()
123     {
124         return `
125             <a href="#" title="Zoom" id="zoom-button" style="display:none;">
126                 <svg viewBox="0 0 100 100">
127                     <g stroke-width="0" stroke="none">
128                         <polygon points="25,25 5,50 25,75"/>
129                         <polygon points="75,25 95,50 75,75"/>
130                     </g>
131                     <line x1="20" y1="50" x2="80" y2="50" stroke-width="10"></line>
132                 </svg>
133             </a>
134             <span id="annotation-label" style="display:none;"></span>
135         `;
136     }
137
138     static cssTemplate()
139     {
140         return TimeSeriesChart.cssTemplate() + `
141             #zoom-button {
142                 position: absolute;
143                 left: 0;
144                 top: 0;
145                 width: 1rem;
146                 height: 1rem;
147                 display: block;
148                 background: rgba(255, 255, 255, 0.8);
149                 -webkit-backdrop-filter: blur(0.3rem);
150                 stroke: #666;
151                 fill: #666;
152                 border: solid 1px #ccc;
153                 border-radius: 0.2rem;
154                 z-index: 20;
155             }
156
157             #annotation-label {
158                 position: absolute;
159                 left: 0;
160                 top: 0;
161                 display: inline;
162                 background: rgba(255, 255, 255, 0.8);
163                 -webkit-backdrop-filter: blur(0.5rem);
164                 color: #000;
165                 border: solid 1px #ccc;
166                 border-radius: 0.2rem;
167                 padding: 0.2rem;
168                 font-size: 0.8rem;
169                 font-weight: normal;
170                 line-height: 0.9rem;
171                 z-index: 10;
172                 max-width: 15rem;
173             }
174         `;
175     }
176
177     _mouseMove(event)
178     {
179         var cursorLocation = {x: event.offsetX, y: event.offsetY};
180         if (this._startOrContinueDragging(cursorLocation, false) || this._selectionTimeRange)
181             return;
182
183         if (this._indicatorIsLocked)
184             return;
185
186         var oldIndicatorID = this._indicatorID;
187
188         var newAnnotation = this._findAnnotation(cursorLocation);
189         var newIndicatorID = null;
190         if (this._currentAnnotation)
191             newIndicatorID = null;
192         else
193             newIndicatorID = this._findClosestPoint(cursorLocation);
194
195         this._forceRender = true;
196         this.enqueueToRender();
197
198         if (this._currentAnnotation == newAnnotation && this._indicatorID == newIndicatorID)
199             return;
200
201         this._currentAnnotation = newAnnotation;
202         this._indicatorID = newIndicatorID;
203
204         this._notifyIndicatorChanged();
205     }
206
207     _mouseLeave(event)
208     {
209         if (this._selectionTimeRange || this._indicatorIsLocked || !this._indicatorID)
210             return;
211
212         this._indicatorID = null;
213         this._forceRender = true;
214         this.enqueueToRender();
215         this._notifyIndicatorChanged();
216     }
217
218     _mouseDown(event)
219     {
220         this._lastMouseDownLocation = {x: event.offsetX, y: event.offsetY};
221     }
222
223     _mouseUp(event)
224     {
225         if (this._dragStarted)
226             this._endDragging({x: event.offsetX, y: event.offsetY});
227     }
228
229     _click(event)
230     {
231         if (this._selectionTimeRange) {
232             if (!this._didEndDrag) {
233                 this._lastMouseDownLocation = null;
234                 this._selectionTimeRange = null;
235                 this._notifySelectionChanged(true);
236                 this._mouseMove(event);
237             }
238             return;
239         }
240
241         this._lastMouseDownLocation = null;
242
243         var cursorLocation = {x: event.offsetX, y: event.offsetY};
244         var annotation = this._findAnnotation(cursorLocation);
245         if (annotation) {
246             this.dispatchAction('annotationClick', annotation);
247             return;
248         }
249
250         this._indicatorIsLocked = !this._indicatorIsLocked;
251         this._indicatorID = this._findClosestPoint(cursorLocation);
252         this._forceRender = true;
253         this.enqueueToRender();
254
255         this._notifyIndicatorChanged();
256     }
257
258     _startOrContinueDragging(cursorLocation, didEndDrag)
259     {
260         var mouseDownLocation = this._lastMouseDownLocation;
261         if (!mouseDownLocation || !this._options.selection)
262             return false;
263
264         var xDiff = mouseDownLocation.x - cursorLocation.x;
265         var yDiff = mouseDownLocation.y - cursorLocation.y;
266         if (!this._dragStarted && xDiff * xDiff + yDiff * yDiff < 10)
267             return false;
268         this._dragStarted = true;
269
270         var indicatorDidChange = !!this._indicatorID;
271         this._indicatorID = null;
272         this._indicatorIsLocked = false;
273
274         var metrics = this._layout();
275
276         var oldSelection = this._selectionTimeRange;
277         if (!didEndDrag) {
278             var selectionStart = Math.min(mouseDownLocation.x, cursorLocation.x);
279             var selectionEnd = Math.max(mouseDownLocation.x, cursorLocation.x);
280             this._selectionTimeRange = [metrics.xToTime(selectionStart), metrics.xToTime(selectionEnd)];
281         }
282         this._forceRender = true;
283         this.enqueueToRender();
284
285         if (indicatorDidChange)
286             this._notifyIndicatorChanged();
287
288         var selectionDidChange = !oldSelection ||
289             oldSelection[0] != this._selectionTimeRange[0] || oldSelection[1] != this._selectionTimeRange[1];
290         if (selectionDidChange || didEndDrag)
291             this._notifySelectionChanged(didEndDrag);
292
293         return true;
294     }
295
296     _endDragging(cursorLocation)
297     {
298         if (!this._startOrContinueDragging(cursorLocation, true))
299             return;
300         this._dragStarted = false;
301         this._lastMouseDownLocation = null;
302         this._didEndDrag = true;
303         setTimeout(() => this._didEndDrag = false, 0);
304     }
305
306     _notifyIndicatorChanged()
307     {
308         this.dispatchAction('indicatorChange', this._indicatorID, this._indicatorIsLocked);
309     }
310
311     _notifySelectionChanged(didEndDrag)
312     {
313         this.dispatchAction('selectionChange', this._selectionTimeRange, didEndDrag);
314     }
315
316     _findAnnotation(cursorLocation)
317     {
318         if (!this._annotations)
319             return null;
320
321         for (var item of this._annotations) {
322             if (item.x <= cursorLocation.x && cursorLocation.x <= item.x + item.width
323                 && item.y <= cursorLocation.y && cursorLocation.y <= item.y + item.height)
324                 return item;
325         }
326         return null;
327     }
328
329     _findClosestPoint(cursorLocation)
330     {
331         Instrumentation.startMeasuringTime('InteractiveTimeSeriesChart', 'findClosestPoint');
332
333         var metrics = this._layout();
334
335         function weightedDistance(point) {
336             var x = metrics.timeToX(point.time);
337             var y = metrics.valueToY(point.value);
338             var xDiff = cursorLocation.x - x;
339             var yDiff = cursorLocation.y - y;
340             return xDiff * xDiff + yDiff * yDiff / 16; // Favor horizontal affinity.
341         }
342
343         var minDistance;
344         var minPoint = null;
345         for (var i = 0; i < this._sampledTimeSeriesData.length; i++) {
346             var series = this._sampledTimeSeriesData[i];
347             var source = this._sourceList[i];
348             if (!series || !source.interactive)
349                 continue;
350             for (var point of series) {
351                 var distance = weightedDistance(point);
352                 if (minDistance === undefined || distance < minDistance) {
353                     minDistance = distance;
354                     minPoint = point;
355                 }
356             }
357         }
358
359         Instrumentation.endMeasuringTime('InteractiveTimeSeriesChart', 'findClosestPoint');
360
361         return minPoint ? minPoint.id : null;
362     }
363
364     _layout()
365     {
366         var metrics = super._layout();
367         metrics.doneWork |= this._forceRender;
368         this._forceRender = false;
369         this._lastRenderigMetrics = metrics;
370         return metrics;
371     }
372
373     _sampleTimeSeries(data, maximumNumberOfPoints, excludedPoints)
374     {
375         if (this._indicatorID)
376             excludedPoints.add(this._indicatorID);
377         return super._sampleTimeSeries(data, maximumNumberOfPoints, excludedPoints);
378     }
379
380     _renderChartContent(context, metrics)
381     {
382         super._renderChartContent(context, metrics);
383
384         Instrumentation.startMeasuringTime('InteractiveTimeSeriesChart', 'renderChartContent');
385
386         context.lineJoin = 'miter';
387
388         if (this._renderedAnnotation != this._currentAnnotation) {
389             this._renderedAnnotation = this._currentAnnotation;
390
391             var annotation = this._currentAnnotation;
392             if (annotation) {
393                 var label = annotation.label;
394                 var spacing = this._options.annotations.minWidth;
395
396                 this._annotationLabel.textContent = label;
397                 if (this._annotationLabel.style.display != 'inline')
398                     this._annotationLabel.style.display = 'inline';
399
400                 // Force a browser layout.
401                 var labelWidth = this._annotationLabel.offsetWidth;
402                 var labelHeight = this._annotationLabel.offsetHeight;
403
404                 var centerX = annotation.x + annotation.width / 2 - labelWidth / 2;
405                 var maxX = metrics.chartX + metrics.chartWidth - labelWidth - 2;
406
407                 var x = Math.round(Math.min(maxX, Math.max(metrics.chartX + 2, centerX)));
408                 var y = Math.floor(annotation.y - labelHeight - 1);
409
410                 // Use transform: translate to position the label to avoid triggering another browser layout.
411                 this._annotationLabel.style.transform = `translate(${x}px, ${y}px)`;
412             } else
413                 this._annotationLabel.style.display = 'none';
414         }
415
416         const indicatorOptions = this._options.indicator;
417         const indicator = this.currentIndicator();
418         if (indicator && indicatorOptions) {
419             context.fillStyle = indicatorOptions.lineStyle;
420             context.strokeStyle = indicatorOptions.lineStyle;
421             context.lineWidth = indicatorOptions.lineWidth;
422
423             const x = metrics.timeToX(indicator.point.time);
424             const y = metrics.valueToY(indicator.point.value);
425
426             context.beginPath();
427             context.moveTo(x, metrics.chartY);
428             context.lineTo(x, metrics.chartY + metrics.chartHeight);
429             context.stroke();
430
431             this._fillCircle(context, x, y, indicatorOptions.pointRadius);
432         }
433
434         var selectionOptions = this._options.selection;
435         var selectionX2 = 0;
436         var selectionY2 = 0;
437         if (this._selectionTimeRange && selectionOptions) {
438             context.fillStyle = selectionOptions.fillStyle;
439             context.strokeStyle = selectionOptions.lineStyle;
440             context.lineWidth = selectionOptions.lineWidth;
441
442             var x1 = metrics.timeToX(this._selectionTimeRange[0]);
443             var x2 = metrics.timeToX(this._selectionTimeRange[1]);
444             context.beginPath();
445             selectionX2 = x2;
446             selectionY2 = metrics.chartHeight - selectionOptions.lineWidth;
447             context.rect(x1, metrics.chartY + selectionOptions.lineWidth / 2,
448                 x2 - x1, metrics.chartHeight - selectionOptions.lineWidth);
449             context.fill();
450             context.stroke();
451         }
452
453         if (this._renderedSelection != selectionX2) {
454             this._renderedSelection = selectionX2;
455             if (this._renderedSelection && this._options.zoomButton
456                 && selectionX2 > 0 && selectionX2 < metrics.chartX + metrics.chartWidth) {
457                 if (this._zoomButton.style.display)
458                     this._zoomButton.style.display = null;
459
460                 this._zoomButton.style.left = Math.round(selectionX2 + metrics.fontSize / 4) + 'px';
461                 this._zoomButton.style.top = Math.floor(selectionY2 - metrics.fontSize * 1.5 - 2) + 'px';
462             } else
463                 this._zoomButton.style.display = 'none';
464         }
465
466         Instrumentation.endMeasuringTime('InteractiveTimeSeriesChart', 'renderChartContent');
467     }
468 }
469
470 ComponentBase.defineElement('interactive-time-series-chart', InteractiveTimeSeriesChart);