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