resources/perf.webkit.org/public/v3/components/interactive-time-series-chart.js (401 lines of code) (raw):

class InteractiveTimeSeriesChart extends TimeSeriesChart { constructor(sourceList, options) { super(sourceList, options); this._indicatorID = null; this._indicatorIsLocked = false; this._currentAnnotation = null; this._forceRender = false; this._lastMouseDownLocation = null; this._dragStarted = false; this._didEndDrag = false; this._selectionTimeRange = null; this._renderedSelection = null; this._annotationLabel = null; this._renderedAnnotation = null; } currentIndicator() { var id = this._indicatorID; if (!id) return null; if (!this._sampledTimeSeriesData) return null; for (var view of this._sampledTimeSeriesData) { if (!view) continue; let point = view.findById(id); if (!point) continue; return {view, point, isLocked: this._indicatorIsLocked}; } return null; } currentSelection() { return this._selectionTimeRange; } selectedPoints(type) { const selection = this._selectionTimeRange; const data = this.sampledTimeSeriesData(type); return selection && data ? data.viewTimeRange(selection[0], selection[1]) : null; } firstSelectedPoint(type) { const selection = this._selectionTimeRange; const data = this.sampledTimeSeriesData(type); return selection && data ? data.firstPointInTimeRange(selection[0], selection[1]) : null; } referencePoints(type) { const selection = this.currentSelection(); if (selection) { const view = this.selectedPoints('current'); if (!view) return null; const firstPoint = view.lastPoint(); const lastPoint = view.firstPoint(); if (!firstPoint) return null; return {view, currentPoint: firstPoint, previousPoint: firstPoint != lastPoint ? lastPoint : null}; } else { const indicator = this.currentIndicator(); if (!indicator) return null; return {view: indicator.view, currentPoint: indicator.point, previousPoint: indicator.view.previousPoint(indicator.point)}; } return null; } setIndicator(id, shouldLock) { var selectionDidChange = !!this._sampledTimeSeriesData; this._indicatorID = id; this._indicatorIsLocked = shouldLock; this._lastMouseDownLocation = null; this._selectionTimeRange = null; this._forceRender = true; if (selectionDidChange) this._notifySelectionChanged(false); } moveLockedIndicatorWithNotification(forward) { const indicator = this.currentIndicator(); if (!indicator || !indicator.isLocked) return false; console.assert(!this._selectionTimeRange); const constrainedView = indicator.view.viewTimeRange(this._startTime, this._endTime); const newPoint = forward ? constrainedView.nextPoint(indicator.point) : constrainedView.previousPoint(indicator.point); if (!newPoint || this._indicatorID == newPoint.id) return false; this._indicatorID = newPoint.id; this._lastMouseDownLocation = null; this._forceRender = true; this.enqueueToRender(); this._notifyIndicatorChanged(); } setSelection(newSelectionTimeRange) { var indicatorDidChange = !!this._indicatorID; this._indicatorID = null; this._indicatorIsLocked = false; this._lastMouseDownLocation = null; this._selectionTimeRange = newSelectionTimeRange; this._forceRender = true; if (indicatorDidChange) this._notifyIndicatorChanged(); } _createCanvas() { var canvas = super._createCanvas(); canvas.addEventListener('mousemove', this._mouseMove.bind(this)); canvas.addEventListener('mouseleave', this._mouseLeave.bind(this)); canvas.addEventListener('mousedown', this._mouseDown.bind(this)); window.addEventListener('mouseup', this._mouseUp.bind(this)); canvas.addEventListener('click', this._click.bind(this)); this._annotationLabel = this.content('annotation-label'); this._zoomButton = this.content('zoom-button'); this._zoomButton.onclick = (event) => { event.preventDefault(); this.dispatchAction('zoom', this._selectionTimeRange); } return canvas; } static htmlTemplate() { return ` <a href="#" title="Zoom" id="zoom-button" style="display:none;"> <svg viewBox="0 0 100 100"> <g stroke-width="0" stroke="none"> <polygon points="25,25 5,50 25,75"/> <polygon points="75,25 95,50 75,75"/> </g> <line x1="20" y1="50" x2="80" y2="50" stroke-width="10"></line> </svg> </a> <span id="annotation-label" style="display:none;"></span> `; } static cssTemplate() { return TimeSeriesChart.cssTemplate() + ` #zoom-button { position: absolute; left: 0; top: 0; width: 1rem; height: 1rem; display: block; background: rgba(255, 255, 255, 0.8); -webkit-backdrop-filter: blur(0.3rem); stroke: #666; fill: #666; border: solid 1px #ccc; border-radius: 0.2rem; z-index: 20; } #annotation-label { position: absolute; left: 0; top: 0; display: inline; background: rgba(255, 255, 255, 0.8); -webkit-backdrop-filter: blur(0.5rem); color: #000; border: solid 1px #ccc; border-radius: 0.2rem; padding: 0.2rem; font-size: 0.8rem; font-weight: normal; line-height: 0.9rem; z-index: 10; max-width: 15rem; } `; } _mouseMove(event) { var cursorLocation = {x: event.offsetX, y: event.offsetY}; if (this._startOrContinueDragging(cursorLocation, false) || this._selectionTimeRange) return; if (this._indicatorIsLocked) return; var oldIndicatorID = this._indicatorID; var newAnnotation = this._findAnnotation(cursorLocation); var newIndicatorID = null; if (this._currentAnnotation) newIndicatorID = null; else newIndicatorID = this._findClosestPoint(cursorLocation); this._forceRender = true; this.enqueueToRender(); if (this._currentAnnotation == newAnnotation && this._indicatorID == newIndicatorID) return; this._currentAnnotation = newAnnotation; this._indicatorID = newIndicatorID; this._notifyIndicatorChanged(); } _mouseLeave(event) { if (this._selectionTimeRange || this._indicatorIsLocked || !this._indicatorID) return; this._indicatorID = null; this._forceRender = true; this.enqueueToRender(); this._notifyIndicatorChanged(); } _mouseDown(event) { this._lastMouseDownLocation = {x: event.offsetX, y: event.offsetY}; } _mouseUp(event) { if (this._dragStarted) this._endDragging({x: event.offsetX, y: event.offsetY}); } _click(event) { if (this._selectionTimeRange) { if (!this._didEndDrag) { this._lastMouseDownLocation = null; this._selectionTimeRange = null; this._notifySelectionChanged(true); this._mouseMove(event); } return; } this._lastMouseDownLocation = null; var cursorLocation = {x: event.offsetX, y: event.offsetY}; var annotation = this._findAnnotation(cursorLocation); if (annotation) { this.dispatchAction('annotationClick', annotation); return; } this._indicatorIsLocked = !this._indicatorIsLocked; this._indicatorID = this._findClosestPoint(cursorLocation); this._forceRender = true; this.enqueueToRender(); this._notifyIndicatorChanged(); } _startOrContinueDragging(cursorLocation, didEndDrag) { var mouseDownLocation = this._lastMouseDownLocation; if (!mouseDownLocation || !this._options.selection) return false; var xDiff = mouseDownLocation.x - cursorLocation.x; var yDiff = mouseDownLocation.y - cursorLocation.y; if (!this._dragStarted && xDiff * xDiff + yDiff * yDiff < 10) return false; this._dragStarted = true; var indicatorDidChange = !!this._indicatorID; this._indicatorID = null; this._indicatorIsLocked = false; var metrics = this._layout(); var oldSelection = this._selectionTimeRange; if (!didEndDrag) { var selectionStart = Math.min(mouseDownLocation.x, cursorLocation.x); var selectionEnd = Math.max(mouseDownLocation.x, cursorLocation.x); this._selectionTimeRange = [metrics.xToTime(selectionStart), metrics.xToTime(selectionEnd)]; } this._forceRender = true; this.enqueueToRender(); if (indicatorDidChange) this._notifyIndicatorChanged(); var selectionDidChange = !oldSelection || oldSelection[0] != this._selectionTimeRange[0] || oldSelection[1] != this._selectionTimeRange[1]; if (selectionDidChange || didEndDrag) this._notifySelectionChanged(didEndDrag); return true; } _endDragging(cursorLocation) { if (!this._startOrContinueDragging(cursorLocation, true)) return; this._dragStarted = false; this._lastMouseDownLocation = null; this._didEndDrag = true; setTimeout(() => this._didEndDrag = false, 0); } _notifyIndicatorChanged() { this.dispatchAction('indicatorChange', this._indicatorID, this._indicatorIsLocked); } _notifySelectionChanged(didEndDrag) { this.dispatchAction('selectionChange', this._selectionTimeRange, didEndDrag); } _findAnnotation(cursorLocation) { if (!this._annotations) return null; for (const item of this._annotations) { if (item.x <= cursorLocation.x && cursorLocation.x <= item.x + item.width && item.y <= cursorLocation.y && cursorLocation.y <= item.y + item.height) return item; } return null; } _findClosestPoint(cursorLocation) { Instrumentation.startMeasuringTime('InteractiveTimeSeriesChart', 'findClosestPoint'); var metrics = this._layout(); function weightedDistance(point) { var x = metrics.timeToX(point.time); var y = metrics.valueToY(point.value); var xDiff = cursorLocation.x - x; var yDiff = cursorLocation.y - y; return xDiff * xDiff + yDiff * yDiff / 16; // Favor horizontal affinity. } var minDistance; var minPoint = null; for (var i = 0; i < this._sampledTimeSeriesData.length; i++) { var series = this._sampledTimeSeriesData[i]; var source = this._sourceList[i]; if (!series || !source.interactive) continue; for (let point = series.firstPoint(); point; point = series.nextPoint(point)) { var distance = weightedDistance(point); if (minDistance === undefined || distance < minDistance) { minDistance = distance; minPoint = point; } } } Instrumentation.endMeasuringTime('InteractiveTimeSeriesChart', 'findClosestPoint'); return minPoint ? minPoint.id : null; } _layout() { var metrics = super._layout(); metrics.doneWork |= this._forceRender; this._forceRender = false; return metrics; } _sampleTimeSeries(data, maximumNumberOfPoints, excludedPoints) { if (this._indicatorID) excludedPoints.add(this._indicatorID); return super._sampleTimeSeries(data, maximumNumberOfPoints, excludedPoints); } _renderChartContent(context, metrics) { super._renderChartContent(context, metrics); Instrumentation.startMeasuringTime('InteractiveTimeSeriesChart', 'renderChartContent'); context.lineJoin = 'miter'; if (this._renderedAnnotation != this._currentAnnotation) { this._renderedAnnotation = this._currentAnnotation; var annotation = this._currentAnnotation; if (annotation) { var label = annotation.label; var spacing = this._options.annotations.minWidth; this._annotationLabel.textContent = label; if (this._annotationLabel.style.display != 'inline') this._annotationLabel.style.display = 'inline'; // Force a browser layout. var labelWidth = this._annotationLabel.offsetWidth; var labelHeight = this._annotationLabel.offsetHeight; var centerX = annotation.x + annotation.width / 2 - labelWidth / 2; var maxX = metrics.chartX + metrics.chartWidth - labelWidth - 2; var x = Math.round(Math.min(maxX, Math.max(metrics.chartX + 2, centerX))); var y = Math.floor(annotation.y - labelHeight - 1); // Use transform: translate to position the label to avoid triggering another browser layout. this._annotationLabel.style.transform = `translate(${x}px, ${y}px)`; } else this._annotationLabel.style.display = 'none'; } const indicator = this.currentIndicator(); const indicatorOptions = (indicator && indicator.isLocked ? this._options.lockedIndicator : null) || this._options.indicator; if (indicator && indicatorOptions) { context.fillStyle = indicatorOptions.fillStyle || indicatorOptions.lineStyle; context.strokeStyle = indicatorOptions.lineStyle; context.lineWidth = indicatorOptions.lineWidth; const x = metrics.timeToX(indicator.point.time); const y = metrics.valueToY(indicator.point.value); context.beginPath(); context.moveTo(x, metrics.chartY); context.lineTo(x, metrics.chartY + metrics.chartHeight); context.stroke(); this._fillCircle(context, x, y, indicatorOptions.pointRadius); context.stroke(); } var selectionOptions = this._options.selection; var selectionX2 = 0; var selectionY2 = 0; if (this._selectionTimeRange && selectionOptions) { context.fillStyle = selectionOptions.fillStyle; context.strokeStyle = selectionOptions.lineStyle; context.lineWidth = selectionOptions.lineWidth; var x1 = metrics.timeToX(this._selectionTimeRange[0]); var x2 = metrics.timeToX(this._selectionTimeRange[1]); context.beginPath(); selectionX2 = x2; selectionY2 = metrics.chartHeight - selectionOptions.lineWidth; context.rect(x1, metrics.chartY + selectionOptions.lineWidth / 2, x2 - x1, metrics.chartHeight - selectionOptions.lineWidth); context.fill(); context.stroke(); } if (this._renderedSelection != selectionX2) { this._renderedSelection = selectionX2; if (this._renderedSelection && this._options.zoomButton && selectionX2 > 0 && selectionX2 < metrics.chartX + metrics.chartWidth) { if (this._zoomButton.style.display) this._zoomButton.style.display = null; this._zoomButton.style.left = Math.round(selectionX2 + metrics.fontSize / 4) + 'px'; this._zoomButton.style.top = Math.floor(selectionY2 - metrics.fontSize * 1.5 - 2) + 'px'; } else this._zoomButton.style.display = 'none'; } Instrumentation.endMeasuringTime('InteractiveTimeSeriesChart', 'renderChartContent'); } } ComponentBase.defineElement('interactive-time-series-chart', InteractiveTimeSeriesChart);