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