// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @implements {SDK.SDKModelObserver} * @unrestricted */ PerformanceMonitor.PerformanceMonitor = class extends UI.HBox { constructor() { super(true); this.registerRequiredCSS('performance_monitor/performanceMonitor.css'); this.contentElement.classList.add('perfmon-pane'); /** @type {!Array}>} */ this._metricsBuffer = []; /** @const */ this._pixelsPerMs = 20 / 1000; /** @const */ this._pollIntervalMs = 500; /** @const */ this._scaleHeight = 16; /** @const */ this._graphHeight = 90; this._gridColor = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.08)', UI.ThemeSupport.ColorUsage.Foreground); this._controlPane = new PerformanceMonitor.PerformanceMonitor.ControlPane(this.contentElement); const chartContainer = this.contentElement.createChild('div', 'perfmon-chart-container'); this._canvas = /** @type {!HTMLCanvasElement} */ (chartContainer.createChild('canvas')); this.contentElement.createChild('div', 'perfmon-chart-suspend-overlay fill').createChild('div').textContent = Common.UIString('Paused'); this._controlPane.addEventListener( PerformanceMonitor.PerformanceMonitor.ControlPane.Events.MetricChanged, this._recalcChartHeight, this); SDK.targetManager.observeModels(SDK.PerformanceMetricsModel, this); } /** * @override */ wasShown() { if (!this._model) return; SDK.targetManager.addEventListener(SDK.TargetManager.Events.SuspendStateChanged, this._suspendStateChanged, this); this._model.enable(); this._suspendStateChanged(); } /** * @override */ willHide() { if (!this._model) return; SDK.targetManager.removeEventListener( SDK.TargetManager.Events.SuspendStateChanged, this._suspendStateChanged, this); this._stopPolling(); this._model.disable(); } /** * @override * @param {!SDK.PerformanceMetricsModel} model */ modelAdded(model) { if (this._model) return; this._model = model; if (this.isShowing()) this.wasShown(); } /** * @override * @param {!SDK.PerformanceMetricsModel} model */ modelRemoved(model) { if (this._model !== model) return; if (this.isShowing()) this.willHide(); this._model = null; } _suspendStateChanged() { const suspended = SDK.targetManager.allTargetsSuspended(); if (suspended) this._stopPolling(); else this._startPolling(); this.contentElement.classList.toggle('suspended', suspended); } _startPolling() { this._startTimestamp = 0; this._pollTimer = setInterval(() => this._poll(), this._pollIntervalMs); this.onResize(); animate.call(this); /** * @this {PerformanceMonitor.PerformanceMonitor} */ function animate() { this._draw(); this._animationId = this.contentElement.window().requestAnimationFrame(animate.bind(this)); } } _stopPolling() { clearInterval(this._pollTimer); this.contentElement.window().cancelAnimationFrame(this._animationId); this._metricsBuffer = []; } async _poll() { const data = await this._model.requestMetrics(); const timestamp = data.timestamp; const metrics = data.metrics; this._metricsBuffer.push({timestamp, metrics: metrics}); const millisPerWidth = this._width / this._pixelsPerMs; // Multiply by 2 as the pollInterval has some jitter and to have some extra samples if window is resized. const maxCount = Math.ceil(millisPerWidth / this._pollIntervalMs * 2); if (this._metricsBuffer.length > maxCount * 2) // Multiply by 2 to have a hysteresis. this._metricsBuffer.splice(0, this._metricsBuffer.length - maxCount); this._controlPane.updateMetrics(metrics); } _draw() { const ctx = /** @type {!CanvasRenderingContext2D} */ (this._canvas.getContext('2d')); ctx.save(); ctx.scale(window.devicePixelRatio, window.devicePixelRatio); ctx.clearRect(0, 0, this._width, this._height); ctx.save(); ctx.translate(0, this._scaleHeight); // Reserve space for the scale bar. for (const chartInfo of this._controlPane.charts()) { if (!this._controlPane.isActive(chartInfo.metrics[0].name)) continue; this._drawChart(ctx, chartInfo, this._graphHeight); ctx.translate(0, this._graphHeight); } ctx.restore(); this._drawHorizontalGrid(ctx); ctx.restore(); } /** * @param {!CanvasRenderingContext2D} ctx */ _drawHorizontalGrid(ctx) { const lightGray = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.02)', UI.ThemeSupport.ColorUsage.Foreground); ctx.font = '9px ' + Host.fontFamily(); ctx.fillStyle = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.3)', UI.ThemeSupport.ColorUsage.Foreground); const currentTime = Date.now() / 1000; for (let sec = Math.ceil(currentTime);; --sec) { const x = this._width - ((currentTime - sec) * 1000 - this._pollIntervalMs) * this._pixelsPerMs; if (x < -50) break; ctx.beginPath(); ctx.moveTo(Math.round(x) + 0.5, 0); ctx.lineTo(Math.round(x) + 0.5, this._height); if (sec >= 0 && sec % 5 === 0) ctx.fillText(new Date(sec * 1000).toLocaleTimeString(), Math.round(x) + 4, 12); ctx.strokeStyle = sec % 5 ? lightGray : this._gridColor; ctx.stroke(); } } /** * @param {!CanvasRenderingContext2D} ctx * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} chartInfo * @param {number} height */ _drawChart(ctx, chartInfo, height) { ctx.save(); ctx.rect(0, 0, this._width, height); ctx.clip(); const bottomPadding = 8; const extraSpace = 1.05; const max = this._calcMax(chartInfo) * extraSpace; const stackedChartBaseLandscape = chartInfo.stacked ? new Map() : null; const paths = []; for (let i = chartInfo.metrics.length - 1; i >= 0; --i) { const metricInfo = chartInfo.metrics[i]; paths.push({ path: this._buildMetricPath( chartInfo, metricInfo, height - bottomPadding, max, i ? stackedChartBaseLandscape : null), color: metricInfo.color }); } const backgroundColor = Common.Color.parse(UI.themeSupport.patchColorText('white', UI.ThemeSupport.ColorUsage.Background)); for (const path of paths.reverse()) { const color = path.color; ctx.save(); ctx.fillStyle = backgroundColor.blendWith(Common.Color.parse(color).setAlpha(0.2)).asString(null); ctx.fill(path.path); ctx.strokeStyle = color; ctx.lineWidth = 0.5; ctx.stroke(path.path); ctx.restore(); } this._drawVerticalGrid(ctx, height - bottomPadding, max, chartInfo); ctx.restore(); } /** * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} chartInfo * @return {number} */ _calcMax(chartInfo) { if (chartInfo.max) return chartInfo.max; const width = this._width; const startTime = performance.now() - this._pollIntervalMs - width / this._pixelsPerMs; let max = -Infinity; for (const metricInfo of chartInfo.metrics) { for (let i = this._metricsBuffer.length - 1; i >= 0; --i) { const metrics = this._metricsBuffer[i]; const value = metrics.metrics.get(metricInfo.name); max = Math.max(max, value); if (metrics.timestamp < startTime) break; } } if (!this._metricsBuffer.length) return 10; const base10 = Math.pow(10, Math.floor(Math.log10(max))); max = Math.ceil(max / base10 / 2) * base10 * 2; const alpha = 0.2; chartInfo.currentMax = max * alpha + (chartInfo.currentMax || max) * (1 - alpha); return chartInfo.currentMax; } /** * @param {!CanvasRenderingContext2D} ctx * @param {number} height * @param {number} max * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} info */ _drawVerticalGrid(ctx, height, max, info) { let base = Math.pow(10, Math.floor(Math.log10(max))); const firstDigit = Math.floor(max / base); if (firstDigit !== 1 && firstDigit % 2 === 1) base *= 2; let scaleValue = Math.floor(max / base) * base; const span = max; const topPadding = 5; const visibleHeight = height - topPadding; ctx.fillStyle = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.3)', UI.ThemeSupport.ColorUsage.Foreground); ctx.strokeStyle = this._gridColor; ctx.beginPath(); for (let i = 0; i < 2; ++i) { const y = calcY(scaleValue); const labelText = PerformanceMonitor.PerformanceMonitor.MetricIndicator._formatNumber(scaleValue, info); ctx.moveTo(0, y); ctx.lineTo(4, y); ctx.moveTo(ctx.measureText(labelText).width + 12, y); ctx.lineTo(this._width, y); ctx.fillText(labelText, 8, calcY(scaleValue) + 3); scaleValue /= 2; } ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, height + 0.5); ctx.lineTo(this._width, height + 0.5); ctx.strokeStyle = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.2)', UI.ThemeSupport.ColorUsage.Foreground); ctx.stroke(); /** * @param {number} value * @return {number} */ function calcY(value) { return Math.round(height - visibleHeight * value / span) + 0.5; } } /** * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} chartInfo * @param {!PerformanceMonitor.PerformanceMonitor.MetricInfo} metricInfo * @param {number} height * @param {number} scaleMax * @param {?Map} stackedChartBaseLandscape * @return {!Path2D} */ _buildMetricPath(chartInfo, metricInfo, height, scaleMax, stackedChartBaseLandscape) { const path = new Path2D(); const topPadding = 5; const visibleHeight = height - topPadding; if (visibleHeight < 1) return path; const span = scaleMax; const metricName = metricInfo.name; const pixelsPerMs = this._pixelsPerMs; const startTime = performance.now() - this._pollIntervalMs - this._width / pixelsPerMs; const smooth = chartInfo.smooth; let x = 0; let lastY = 0; let lastX = 0; if (this._metricsBuffer.length) { x = (this._metricsBuffer[0].timestamp - startTime) * pixelsPerMs; path.moveTo(x, calcY(0)); path.lineTo(this._width + 5, calcY(0)); lastY = calcY(this._metricsBuffer.peekLast().metrics.get(metricName)); lastX = this._width + 5; path.lineTo(lastX, lastY); } for (let i = this._metricsBuffer.length - 1; i >= 0; --i) { const metrics = this._metricsBuffer[i]; const timestamp = metrics.timestamp; let value = metrics.metrics.get(metricName); if (stackedChartBaseLandscape) { value += stackedChartBaseLandscape.get(timestamp) || 0; value = Number.constrain(value, 0, 1); stackedChartBaseLandscape.set(timestamp, value); } const y = calcY(value); x = (timestamp - startTime) * pixelsPerMs; if (smooth) { const midX = (lastX + x) / 2; path.bezierCurveTo(midX, lastY, midX, y, x, y); } else { path.lineTo(x, lastY); path.lineTo(x, y); } lastX = x; lastY = y; if (timestamp < startTime) break; } return path; /** * @param {number} value * @return {number} */ function calcY(value) { return Math.round(height - visibleHeight * value / span) + 0.5; } } /** * @override */ onResize() { super.onResize(); this._width = this._canvas.offsetWidth; this._canvas.width = Math.round(this._width * window.devicePixelRatio); this._recalcChartHeight(); } _recalcChartHeight() { let height = this._scaleHeight; for (const chartInfo of this._controlPane.charts()) { if (this._controlPane.isActive(chartInfo.metrics[0].name)) height += this._graphHeight; } this._height = Math.ceil(height * window.devicePixelRatio); this._canvas.height = this._height; this._canvas.style.height = `${this._height / window.devicePixelRatio}px`; } }; /** @enum {symbol} */ PerformanceMonitor.PerformanceMonitor.Format = { Percent: Symbol('Percent'), Bytes: Symbol('Bytes'), }; /** * @typedef {!{ * title: string, * metrics: !Array, * max: (number|undefined), * currentMax: (number|undefined), * format: (!PerformanceMonitor.PerformanceMonitor.Format|undefined), * smooth: (boolean|undefined) * }} */ PerformanceMonitor.PerformanceMonitor.ChartInfo; /** * @typedef {!{ * name: string, * color: string * }} */ PerformanceMonitor.PerformanceMonitor.MetricInfo; PerformanceMonitor.PerformanceMonitor.ControlPane = class extends Common.Object { /** * @param {!Element} parent */ constructor(parent) { super(); this.element = parent.createChild('div', 'perfmon-control-pane'); this._enabledChartsSetting = Common.settings.createSetting('perfmonActiveIndicators2', ['TaskDuration', 'JSHeapTotalSize', 'Nodes']); /** @type {!Set} */ this._enabledCharts = new Set(this._enabledChartsSetting.get()); const format = PerformanceMonitor.PerformanceMonitor.Format; /** @type {!Array} */ this._chartsInfo = [ { title: Common.UIString('CPU usage'), metrics: [ {name: 'TaskDuration', color: '#999'}, {name: 'ScriptDuration', color: 'orange'}, {name: 'LayoutDuration', color: 'blueviolet'}, {name: 'RecalcStyleDuration', color: 'violet'} ], format: format.Percent, smooth: true, stacked: true, color: 'red', max: 1 }, { title: Common.UIString('JS heap size'), metrics: [{name: 'JSHeapTotalSize', color: '#99f'}, {name: 'JSHeapUsedSize', color: 'blue'}], format: format.Bytes, color: 'blue' }, {title: Common.UIString('DOM Nodes'), metrics: [{name: 'Nodes', color: 'green'}]}, {title: Common.UIString('JS event listeners'), metrics: [{name: 'JSEventListeners', color: 'yellowgreen'}]}, {title: Common.UIString('Documents'), metrics: [{name: 'Documents', color: 'darkblue'}]}, {title: Common.UIString('Document Frames'), metrics: [{name: 'Frames', color: 'darkcyan'}]}, {title: Common.UIString('Layouts / sec'), metrics: [{name: 'LayoutCount', color: 'hotpink'}]}, {title: Common.UIString('Style recalcs / sec'), metrics: [{name: 'RecalcStyleCount', color: 'deeppink'}]} ]; for (const info of this._chartsInfo) { for (const metric of info.metrics) metric.color = UI.themeSupport.patchColorText(metric.color, UI.ThemeSupport.ColorUsage.Foreground); } /** @type {!Map} */ this._indicators = new Map(); for (const chartInfo of this._chartsInfo) { const chartName = chartInfo.metrics[0].name; const active = this._enabledCharts.has(chartName); const indicator = new PerformanceMonitor.PerformanceMonitor.MetricIndicator( this.element, chartInfo, active, this._onToggle.bind(this, chartName)); this._indicators.set(chartName, indicator); } } /** * @param {string} chartName * @param {boolean} active */ _onToggle(chartName, active) { if (active) this._enabledCharts.add(chartName); else this._enabledCharts.delete(chartName); this._enabledChartsSetting.set(Array.from(this._enabledCharts)); this.dispatchEventToListeners(PerformanceMonitor.PerformanceMonitor.ControlPane.Events.MetricChanged); } /** * @return {!Array} */ charts() { return this._chartsInfo; } /** * @param {string} metricName * @return {boolean} */ isActive(metricName) { return this._enabledCharts.has(metricName); } /** * @param {!Map} metrics */ updateMetrics(metrics) { for (const name of this._indicators.keys()) { if (metrics.has(name)) this._indicators.get(name).setValue(metrics.get(name)); } } }; /** @enum {symbol} */ PerformanceMonitor.PerformanceMonitor.ControlPane.Events = { MetricChanged: Symbol('MetricChanged') }; PerformanceMonitor.PerformanceMonitor.MetricIndicator = class { /** * @param {!Element} parent * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} info * @param {boolean} active * @param {function(boolean)} onToggle */ constructor(parent, info, active, onToggle) { const color = info.color || info.metrics[0].color; this._info = info; this._active = active; this._onToggle = onToggle; this.element = parent.createChild('div', 'perfmon-indicator'); this._swatchElement = UI.Icon.create('smallicon-checkmark-square', 'perfmon-indicator-swatch'); this._swatchElement.style.backgroundColor = color; this.element.appendChild(this._swatchElement); this.element.createChild('div', 'perfmon-indicator-title').textContent = info.title; this._valueElement = this.element.createChild('div', 'perfmon-indicator-value'); this._valueElement.style.color = color; this.element.addEventListener('click', () => this._toggleIndicator()); this.element.classList.toggle('active', active); } /** * @param {number} value * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} info * @return {string} */ static _formatNumber(value, info) { switch (info.format) { case PerformanceMonitor.PerformanceMonitor.Format.Percent: return value.toLocaleString('en-US', {maximumFractionDigits: 1, style: 'percent'}); case PerformanceMonitor.PerformanceMonitor.Format.Bytes: return Number.bytesToString(value); default: return value.toLocaleString('en-US', {maximumFractionDigits: 1}); } } /** * @param {number} value */ setValue(value) { this._valueElement.textContent = PerformanceMonitor.PerformanceMonitor.MetricIndicator._formatNumber(value, this._info); } _toggleIndicator() { this._active = !this._active; this.element.classList.toggle('active', this._active); this._onToggle(this._active); } }; PerformanceMonitor.PerformanceMonitor.MetricIndicator._format = new Intl.NumberFormat('en-US', {maximumFractionDigits: 1});