/* * Copyright (C) 2019 Apple Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF * THE POSSIBILITY OF SUCH DAMAGE. */ WI.CPUTimelineView = class CPUTimelineView extends WI.TimelineView { constructor(timeline, extraArguments) { console.assert(timeline.type === WI.TimelineRecord.Type.CPU, timeline); super(timeline, extraArguments); this._recording = extraArguments.recording; this.element.classList.add("cpu"); this._sectionLimit = CPUTimelineView.defaultSectionLimit; this._statisticsData = null; this._secondsPerPixelInLayout = undefined; this._visibleRecordsInLayout = []; this._discontinuitiesInLayout = []; this._stickingOverlay = false; this._overlayRecord = null; this._overlayTime = NaN; timeline.addEventListener(WI.Timeline.Event.RecordAdded, this._cpuTimelineRecordAdded, this); } // Static static displayNameForSampleType(type) { switch (type) { case CPUTimelineView.SampleType.JavaScript: return WI.UIString("JavaScript"); case CPUTimelineView.SampleType.Layout: return WI.repeatedUIString.timelineRecordLayout(); case CPUTimelineView.SampleType.Paint: return WI.repeatedUIString.timelineRecordPaint(); case CPUTimelineView.SampleType.Style: return WI.UIString("Styles"); } console.error("Unknown sample type", type); } static get cpuUsageViewHeight() { return 135; } static get threadCPUUsageViewHeight() { return 65; } static get indicatorViewHeight() { return 15; } static get lowEnergyThreshold() { return 3; } static get mediumEnergyThreshold() { return 30; } static get highEnergyThreshold() { return 100; } static get lowEnergyGraphBoundary() { return 10; } static get mediumEnergyGraphBoundary() { return 70; } static get highEnergyGraphBoundary() { return 100; } static get defaultSectionLimit() { return 5; } // Public attached() { super.attached(); this._timelineRuler?.needsLayout(WI.View.LayoutReason.Resize); } closed() { this.representedObject.removeEventListener(WI.Timeline.Event.RecordAdded, this._cpuTimelineRecordAdded, this); } reset() { super.reset(); this._resetSourcesFilters(); this.clear(); } clear() { if (!this.didInitialLayout) return; this._breakdownChart.clear(); this._breakdownChart.needsLayout(); this._clearBreakdownLegend(); this._energyChart.clear(); this._energyChart.needsLayout(); this._clearEnergyImpactText(); this._clearStatistics(); this._clearSources(); function clearUsageView(view) { view.clear(); let markersElement = view.chart.element.querySelector(".markers"); if (markersElement) markersElement.remove(); } clearUsageView(this._cpuUsageView); clearUsageView(this._mainThreadUsageView); clearUsageView(this._webkitThreadUsageView); clearUsageView(this._unknownThreadUsageView); this._removeWorkerThreadViews(); this._sectionLimit = CPUTimelineView.defaultSectionLimit; this._statisticsData = null; this._secondsPerPixelInLayout = undefined; this._visibleRecordsInLayout = []; this._discontinuitiesInLayout = []; this._stickingOverlay = false; this._hideGraphOverlay(); } // Protected get showsFilterBar() { return false; } get scrollableElements() { return [this.element]; } initialLayout() { super.initialLayout(); this.element.style.setProperty("--cpu-usage-combined-view-height", CPUTimelineView.cpuUsageViewHeight + "px"); this.element.style.setProperty("--cpu-usage-view-height", CPUTimelineView.threadCPUUsageViewHeight + "px"); this.element.style.setProperty("--cpu-usage-indicator-view-height", CPUTimelineView.indicatorViewHeight + "px"); let contentElement = this.element.appendChild(document.createElement("div")); contentElement.classList.add("content"); let overviewElement = contentElement.appendChild(document.createElement("div")); overviewElement.classList.add("overview"); function createChartContainer(parentElement, subtitle, tooltip) { let chartElement = parentElement.appendChild(document.createElement("div")); chartElement.classList.add("chart"); let chartSubtitleElement = chartElement.appendChild(document.createElement("div")); chartSubtitleElement.classList.add("subtitle"); chartSubtitleElement.textContent = subtitle; if (tooltip) chartSubtitleElement.title = tooltip; let chartFlexContainerElement = chartElement.appendChild(document.createElement("div")); chartFlexContainerElement.classList.add("container"); return chartFlexContainerElement; } function appendLegendRow(legendElement, sampleType) { let rowElement = legendElement.appendChild(document.createElement("div")); rowElement.classList.add("row"); let swatchElement = rowElement.appendChild(document.createElement("div")); swatchElement.classList.add("swatch", sampleType); let valueContainer = rowElement.appendChild(document.createElement("div")); let labelElement = valueContainer.appendChild(document.createElement("div")); labelElement.classList.add("label"); labelElement.textContent = WI.CPUTimelineView.displayNameForSampleType(sampleType); let sizeElement = valueContainer.appendChild(document.createElement("div")); sizeElement.classList.add("size"); return sizeElement; } let breakdownChartContainerElement = createChartContainer(overviewElement, WI.UIString("Main Thread"), WI.UIString("Breakdown of time spent on the main thread")); this._breakdownChart = new WI.CircleChart({size: 120, innerRadiusRatio: 0.5}); this._breakdownChart.segments = Object.values(WI.CPUTimelineView.SampleType); this.addSubview(this._breakdownChart); breakdownChartContainerElement.appendChild(this._breakdownChart.element); this._breakdownLegendElement = breakdownChartContainerElement.appendChild(document.createElement("div")); this._breakdownLegendElement.classList.add("legend"); this._breakdownLegendScriptElement = appendLegendRow(this._breakdownLegendElement, CPUTimelineView.SampleType.JavaScript); this._breakdownLegendLayoutElement = appendLegendRow(this._breakdownLegendElement, CPUTimelineView.SampleType.Layout); this._breakdownLegendPaintElement = appendLegendRow(this._breakdownLegendElement, CPUTimelineView.SampleType.Paint); this._breakdownLegendStyleElement = appendLegendRow(this._breakdownLegendElement, CPUTimelineView.SampleType.Style); let dividerElement = overviewElement.appendChild(document.createElement("div")); dividerElement.classList.add("divider"); let energyContainerElement = createChartContainer(overviewElement, WI.UIString("Energy Impact"), WI.UIString("Estimated energy impact.")); energyContainerElement.classList.add("energy"); let energyChartElement = energyContainerElement.parentElement; let energySubtitleElement = energyChartElement.firstChild; let energyInfoElement = energySubtitleElement.appendChild(document.createElement("span")); energyInfoElement.classList.add("info", WI.Popover.IgnoreAutoDismissClassName); energyInfoElement.textContent = "?"; this._energyInfoPopover = null; this._energyInfoPopoverContentElement = null; energyInfoElement.addEventListener("click", (event) => { if (!this._energyInfoPopover) this._energyInfoPopover = new WI.Popover; if (!this._energyInfoPopoverContentElement) { this._energyInfoPopoverContentElement = document.createElement("div"); this._energyInfoPopoverContentElement.className = "energy-info-popover-content"; const precision = 0; let lowPercent = Number.percentageString(CPUTimelineView.lowEnergyThreshold / 100, precision); let p1 = this._energyInfoPopoverContentElement.appendChild(document.createElement("p")); p1.textContent = WI.UIString("Periods of high CPU utilization will rapidly drain battery. Strive to keep idle pages under %s average CPU utilization.").format(lowPercent); let p2 = this._energyInfoPopoverContentElement.appendChild(document.createElement("p")); p2.textContent = WI.UIString("There is an incurred energy penalty each time the page enters script. This commonly happens with timers, event handlers, and observers."); let p3 = this._energyInfoPopoverContentElement.appendChild(document.createElement("p")); p3.textContent = WI.UIString("To improve CPU utilization reduce or batch workloads when the page is not visible or during times when the page is not being interacted with."); } let isRTL = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL; let preferredEdges = isRTL ? [WI.RectEdge.MAX_Y, WI.RectEdge.MIN_X] : [WI.RectEdge.MAX_Y, WI.RectEdge.MAX_X]; let calculateTargetFrame = () => WI.Rect.rectFromClientRect(energyInfoElement.getBoundingClientRect()).pad(3); this._energyInfoPopover.presentNewContentWithFrame(this._energyInfoPopoverContentElement, calculateTargetFrame(), preferredEdges); this._energyInfoPopover.windowResizeHandler = () => { this._energyInfoPopover.present(calculateTargetFrame(), preferredEdges); }; }); this._energyChart = new WI.GaugeChart({ height: 110, strokeWidth: 20, segments: [ {className: "low", limit: CPUTimelineView.lowEnergyGraphBoundary}, {className: "medium", limit: CPUTimelineView.mediumEnergyGraphBoundary}, {className: "high", limit: CPUTimelineView.highEnergyGraphBoundary}, ] }); this.addSubview(this._energyChart); energyContainerElement.appendChild(this._energyChart.element); let energyTextContainerElement = energyContainerElement.appendChild(document.createElement("div")); this._energyImpactLabelElement = energyTextContainerElement.appendChild(document.createElement("div")); this._energyImpactLabelElement.className = "energy-impact"; this._energyImpactNumberElement = energyTextContainerElement.appendChild(document.createElement("div")); this._energyImpactNumberElement.className = "energy-impact-number"; this._energyImpactDurationElement = energyTextContainerElement.appendChild(document.createElement("div")); this._energyImpactDurationElement.className = "energy-impact-number"; let detailsContainerElement = contentElement.appendChild(document.createElement("div")); detailsContainerElement.classList.add("details"); this._timelineRuler = new WI.TimelineRuler; this._timelineRuler.zeroTime = this.zeroTime; this._timelineRuler.startTime = this.startTime; this._timelineRuler.endTime = this.endTime; this.addSubview(this._timelineRuler); detailsContainerElement.appendChild(this._timelineRuler.element); // Cause the TimelineRuler to layout now so we will have some of its // important properties initialized for our layout. this._timelineRuler.updateLayout(WI.View.LayoutReason.Resize); let detailsSubtitleElement = detailsContainerElement.appendChild(document.createElement("div")); detailsSubtitleElement.classList.add("subtitle"); detailsSubtitleElement.textContent = WI.UIString("CPU Usage"); this._cpuUsageView = new WI.CPUUsageCombinedView(WI.UIString("Total")); this.addSubview(this._cpuUsageView); detailsContainerElement.appendChild(this._cpuUsageView.element); this._cpuUsageView.rangeChart.element.addEventListener("click", this._handleIndicatorClick.bind(this)); this._threadsDetailsElement = detailsContainerElement.appendChild(document.createElement("details")); this._threadsDetailsElement.open = WI.settings.cpuTimelineThreadDetailsExpanded.value; this._threadsDetailsElement.addEventListener("toggle", (event) => { WI.settings.cpuTimelineThreadDetailsExpanded.value = this._threadsDetailsElement.open; if (this._threadsDetailsElement.open) this.updateLayout(WI.CPUTimelineView.LayoutReason.Internal); }); let threadsSubtitleElement = this._threadsDetailsElement.appendChild(document.createElement("summary")); threadsSubtitleElement.classList.add("subtitle", "threads", "expandable"); threadsSubtitleElement.textContent = WI.UIString("Threads"); this._mainThreadUsageView = new WI.CPUUsageView(WI.UIString("Main Thread")); this._mainThreadUsageView.element.classList.add("main-thread"); this.addSubview(this._mainThreadUsageView); this._threadsDetailsElement.appendChild(this._mainThreadUsageView.element); this._webkitThreadUsageView = new WI.CPUUsageView(WI.UIString("WebKit Threads")); this.addSubview(this._webkitThreadUsageView); this._threadsDetailsElement.appendChild(this._webkitThreadUsageView.element); this._unknownThreadUsageView = new WI.CPUUsageView(WI.UIString("Other Threads")); this.addSubview(this._unknownThreadUsageView); this._threadsDetailsElement.appendChild(this._unknownThreadUsageView.element); this._workerViews = []; this._sourcesFilter = { timer: new Set, event: new Set, observer: new Set, }; let bottomOverviewElement = contentElement.appendChild(document.createElement("div")); bottomOverviewElement.classList.add("overview"); let statisticsContainerElement = createChartContainer(bottomOverviewElement, WI.UIString("Statistics")); statisticsContainerElement.classList.add("stats"); this._statisticsTable = statisticsContainerElement.appendChild(document.createElement("table")); this._statisticsRows = []; { let {headerCell, numberCell} = this._createTableRow(this._statisticsTable); headerCell.textContent = WI.UIString("Network Requests:"); this._networkRequestsNumberElement = numberCell; } { let {headerCell, numberCell} = this._createTableRow(this._statisticsTable); headerCell.textContent = WI.UIString("Script Entries:"); this._scriptEntriesNumberElement = numberCell; } this._clearStatistics(); let bottomDividerElement = bottomOverviewElement.appendChild(document.createElement("div")); bottomDividerElement.classList.add("divider"); let sourcesContainerElement = createChartContainer(bottomOverviewElement, WI.UIString("Sources")); sourcesContainerElement.classList.add("stats"); this._sourcesTable = sourcesContainerElement.appendChild(document.createElement("table")); this._sourcesRows = []; { let {row, headerCell, numberCell, labelCell} = this._createTableRow(this._sourcesTable); headerCell.textContent = WI.UIString("Filter:"); this._sourcesFilterRow = row; this._sourcesFilterRow.hidden = true; this._sourcesFilterNumberElement = numberCell; this._sourcesFilterLabelElement = labelCell; let filterClearElement = numberCell.appendChild(document.createElement("span")); filterClearElement.className = "filter-clear"; filterClearElement.textContent = multiplicationSign; filterClearElement.addEventListener("click", (event) => { this._resetSourcesFilters(); this._layoutStatisticsAndSources(); }); } { let {row, headerCell, numberCell, labelCell} = this._createTableRow(this._sourcesTable); headerCell.textContent = WI.UIString("Timers:"); this._timerInstallationsRow = row; this._timerInstallationsNumberElement = numberCell; this._timerInstallationsLabelElement = labelCell; } { let {row, headerCell, numberCell, labelCell} = this._createTableRow(this._sourcesTable); headerCell.textContent = WI.UIString("Event Handlers:"); this._eventHandlersRow = row; this._eventHandlersNumberElement = numberCell; this._eventHandlersLabelElement = labelCell; } { let {row, headerCell, numberCell, labelCell} = this._createTableRow(this._sourcesTable); headerCell.textContent = WI.UIString("Observer Handlers:"); this._observerHandlersRow = row; this._observerHandlersNumberElement = numberCell; this._observerHandlersLabelElement = labelCell; } this._clearSources(); this.element.addEventListener("click", this._handleGraphClick.bind(this)); this.element.addEventListener("mousemove", this._handleGraphMouseMove.bind(this)); this._overlayMarker = new WI.TimelineMarker(-1, WI.TimelineMarker.Type.TimeStamp); this._timelineRuler.addMarker(this._overlayMarker); } layout() { if (this.layoutReason === WI.View.LayoutReason.Resize) return; if (this.layoutReason !== WI.CPUTimelineView.LayoutReason.Internal) this._sectionLimit = CPUTimelineView.defaultSectionLimit; // Always update timeline ruler. this._timelineRuler.zeroTime = this.zeroTime; this._timelineRuler.startTime = this.startTime; this._timelineRuler.endTime = this.endTime; let secondsPerPixel = this._timelineRuler.secondsPerPixel; if (!secondsPerPixel) return; let graphStartTime = this.startTime; let graphEndTime = this.endTime; // When viewing a timeline recording from JSON, this.currentTime is always 0. let visibleEndTime = Math.min(this.endTime, this.currentTime) || this.endTime; let visibleDuration = visibleEndTime - graphStartTime; let discontinuities = this._recording.discontinuitiesInTimeRange(graphStartTime, visibleEndTime); let originalDiscontinuities = discontinuities.slice(); let visibleRecords = this.representedObject.recordsInTimeRange(graphStartTime, visibleEndTime, { includeRecordBeforeStart: !discontinuities.length || discontinuities[0].startTime > graphStartTime, includeRecordAfterEnd: true, }); if (!visibleRecords.length || (visibleRecords.length === 1 && visibleRecords[0].endTime < graphStartTime)) { this.clear(); return; } this._secondsPerPixelInLayout = secondsPerPixel; this._visibleRecordsInLayout = visibleRecords; this._discontinuitiesInLayout = discontinuities.slice(); this._statisticsData = this._computeStatisticsData(graphStartTime, visibleEndTime); this._layoutBreakdownChart(); this._layoutStatisticsAndSources(); let dataPoints = []; let workersDataMap = new Map; let workersSeenInCurrentRecord = new Set; let max = -Infinity; let mainThreadMax = -Infinity; let webkitThreadMax = -Infinity; let unknownThreadMax = -Infinity; let workerMax = -Infinity; let min = Infinity; let mainThreadMin = Infinity; let webkitThreadMin = Infinity; let unknownThreadMin = Infinity; let average = 0; let mainThreadAverage = 0; let webkitThreadAverage = 0; let unknownThreadAverage = 0; for (let record of visibleRecords) { let time = record.timestamp; let {usage, mainThreadUsage, workerThreadUsage, webkitThreadUsage, unknownThreadUsage} = record; if (discontinuities.length && discontinuities[0].endTime <= time) { let startDiscontinuity = discontinuities.shift(); let endDiscontinuity = startDiscontinuity; while (discontinuities.length && discontinuities[0].endTime <= time) endDiscontinuity = discontinuities.shift(); if (dataPoints.length) { let previousDataPoint = dataPoints.lastValue; dataPoints.push({ time: startDiscontinuity.startTime, mainThreadUsage: previousDataPoint.mainThreadUsage, workerThreadUsage: previousDataPoint.workerThreadUsage, webkitThreadUsage: previousDataPoint.webkitThreadUsage, unknownThreadUsage: previousDataPoint.unknownThreadUsage, usage: previousDataPoint.usage, }); } dataPoints.push({time: startDiscontinuity.startTime, mainThreadUsage: 0, workerThreadUsage: 0, webkitThreadUsage: 0, unknownThreadUsage: 0, usage: 0}); dataPoints.push({time: endDiscontinuity.endTime, mainThreadUsage: 0, workerThreadUsage: 0, webkitThreadUsage: 0, unknownThreadUsage: 0, usage: 0}); dataPoints.push({time: endDiscontinuity.endTime, mainThreadUsage, workerThreadUsage, webkitThreadUsage, unknownThreadUsage, usage}); } dataPoints.push({time, mainThreadUsage, workerThreadUsage, webkitThreadUsage, unknownThreadUsage, usage}); max = Math.max(max, usage); mainThreadMax = Math.max(mainThreadMax, mainThreadUsage); webkitThreadMax = Math.max(webkitThreadMax, webkitThreadUsage); unknownThreadMax = Math.max(unknownThreadMax, unknownThreadUsage); min = Math.min(min, usage); mainThreadMin = Math.min(mainThreadMin, mainThreadUsage); webkitThreadMin = Math.min(webkitThreadMin, webkitThreadUsage); unknownThreadMin = Math.min(unknownThreadMin, unknownThreadUsage); average += usage; mainThreadAverage += mainThreadUsage; webkitThreadAverage += webkitThreadUsage; unknownThreadAverage += unknownThreadUsage; let workersSeenInLastRecord = workersSeenInCurrentRecord; workersSeenInCurrentRecord = new Set; if (record.workersData && record.workersData.length) { for (let {targetId, usage} of record.workersData) { workersSeenInCurrentRecord.add(targetId); let workerData = workersDataMap.get(targetId); if (!workerData) { workerData = { discontinuities: originalDiscontinuities.slice(), recordsCount: 0, dataPoints: [], min: Infinity, max: -Infinity, average: 0 }; while (workerData.discontinuities.length && workerData.discontinuities[0].endTime <= graphStartTime) workerData.discontinuities.shift(); workerData.dataPoints.push({time: graphStartTime, usage: 0}); workerData.dataPoints.push({time, usage: 0}); workersDataMap.set(targetId, workerData); } if (workerData.discontinuities.length && workerData.discontinuities[0].endTime < time) { let startDiscontinuity = workerData.discontinuities.shift(); let endDiscontinuity = startDiscontinuity; while (workerData.discontinuities.length && workerData.discontinuities[0].endTime < time) endDiscontinuity = workerData.discontinuities.shift(); if (workerData.dataPoints.length) { let previousDataPoint = workerData.dataPoints.lastValue; workerData.dataPoints.push({time: startDiscontinuity.startTime, usage: previousDataPoint.usage}); } workerData.dataPoints.push({time: startDiscontinuity.startTime, usage: 0}); workerData.dataPoints.push({time: endDiscontinuity.endTime, usage: 0}); workerData.dataPoints.push({time: endDiscontinuity.endTime, usage}); } workerData.dataPoints.push({time, usage}); workerData.recordsCount += 1; workerData.max = Math.max(workerData.max, usage); workerData.min = Math.min(workerData.min, usage); workerData.average += usage; } } // Close any worker that died by dropping to zero. if (workersSeenInLastRecord.size) { let deadWorkers = workersSeenInLastRecord.difference(workersSeenInCurrentRecord); for (let workerId of deadWorkers) { let workerData = workersDataMap.get(workerId); if (workerData.dataPoints.lastValue.usage !== 0) workerData.dataPoints.push({time, usage: 0}); } } } average /= visibleRecords.length; mainThreadAverage /= visibleRecords.length; webkitThreadAverage /= visibleRecords.length; unknownThreadAverage /= visibleRecords.length; for (let [workerId, workerData] of workersDataMap) { workerData.average = workerData.average / workerData.recordsCount; if (workerData.max > workerMax) workerMax = workerData.max; } // If the graph end time is inside a gap, the last data point should // only be extended to the start of the discontinuity. if (discontinuities.length) visibleEndTime = discontinuities[0].startTime; function bestThreadLayoutMax(value) { if (value > 100) return Math.ceil(value); return (Math.floor(value / 25) + 1) * 25; } function removeGreaterThan(arr, max) { return arr.filter((x) => x <= max); } function markerValuesForMaxValue(max) { if (max < 1) return [0.5]; if (max < 7) return removeGreaterThan([1, 3, 5], max); if (max < 12.5) return removeGreaterThan([5, 10], max); if (max < 20) return removeGreaterThan([5, 10, 15], max); if (max < 30) return removeGreaterThan([10, 20, 30], max); if (max < 50) return removeGreaterThan([15, 30, 45], max); if (max < 100) return removeGreaterThan([25, 50, 75], max); if (max < 200) return removeGreaterThan([50, 100, 150], max); if (max >= 200) { let hundreds = Math.floor(max / 100); let even = (hundreds % 2) === 0; if (even) { let top = hundreds * 100; let bottom = top / 2; return [bottom, top]; } let top = hundreds * 100; let bottom = 100; let mid = (top + bottom) / 2; return [bottom, mid, top]; } } function layoutView(view, property, graphHeight, layoutMax, {dataPoints, min, max, average}) { if (min === Infinity) min = 0; if (max === -Infinity) max = 0; if (layoutMax === -Infinity) layoutMax = 0; let isAllThreadsGraph = property === null; let graphMax = layoutMax * 1.05; function xScale(time) { return (time - graphStartTime) / secondsPerPixel; } let size = new WI.Size(xScale(graphEndTime), graphHeight); function yScale(value) { return size.height - ((value / graphMax) * size.height); } view.updateChart(dataPoints, size, visibleEndTime, min, max, average, xScale, yScale, property); let markersElement = view.chart.element.querySelector(".markers"); if (!markersElement) { markersElement = view.chart.element.appendChild(document.createElement("div")); markersElement.className = "markers"; } markersElement.removeChildren(); let markerValues; if (isAllThreadsGraph) markerValues = markerValuesForMaxValue(max); else { const minimumMarkerTextHeight = 17; let percentPerPixel = 1 / (graphHeight / layoutMax); if (layoutMax < 5) { let minimumDisplayablePercentByTwo = Math.ceil((minimumMarkerTextHeight * percentPerPixel) / 2) * 2; markerValues = [Math.max(minimumDisplayablePercentByTwo, Math.floor(max))]; } else { let minimumDisplayablePercentByFive = Math.ceil((minimumMarkerTextHeight * percentPerPixel) / 5) * 5; markerValues = [Math.max(minimumDisplayablePercentByFive, Math.floor(max))]; } } for (let value of markerValues) { let marginTop = yScale(value); let markerElement = markersElement.appendChild(document.createElement("div")); markerElement.style.marginTop = marginTop.toFixed(2) + "px"; let labelElement = markerElement.appendChild(document.createElement("span")); labelElement.classList.add("label"); const precision = 0; labelElement.innerText = Number.percentageString(value / 100, precision); } } // Layout the combined graph to the maximum total CPU usage. // Layout all the thread graphs to the same time scale, the maximum across threads / thread groups. this._layoutMax = max; this._threadLayoutMax = bestThreadLayoutMax(Math.max(mainThreadMax, webkitThreadMax, unknownThreadMax, workerMax)); layoutView(this._cpuUsageView, null, CPUTimelineView.cpuUsageViewHeight, this._layoutMax, {dataPoints, min, max, average}); if (this._threadsDetailsElement.open) { layoutView(this._mainThreadUsageView, "mainThreadUsage", CPUTimelineView.threadCPUUsageViewHeight, this._threadLayoutMax, {dataPoints, min: mainThreadMin, max: mainThreadMax, average: mainThreadAverage}); layoutView(this._webkitThreadUsageView, "webkitThreadUsage", CPUTimelineView.threadCPUUsageViewHeight, this._threadLayoutMax, {dataPoints, min: webkitThreadMin, max: webkitThreadMax, average: webkitThreadAverage}); layoutView(this._unknownThreadUsageView, "unknownThreadUsage", CPUTimelineView.threadCPUUsageViewHeight, this._threadLayoutMax, {dataPoints, min: unknownThreadMin, max: unknownThreadMax, average: unknownThreadAverage}); this._removeWorkerThreadViews(); for (let [workerId, workerData] of workersDataMap) { let worker = WI.targetManager.targetForIdentifier(workerId); let displayName = worker ? worker.displayName : WI.UIString("Worker Thread"); let workerView = new WI.CPUUsageView(displayName); workerView.element.classList.add("worker-thread"); workerView.__workerId = workerId; this.addSubview(workerView); this._threadsDetailsElement.insertBefore(workerView.element, this._webkitThreadUsageView.element); this._workerViews.push(workerView); layoutView(workerView, "usage", CPUTimelineView.threadCPUUsageViewHeight, this._threadLayoutMax, {dataPoints: workerData.dataPoints, min: workerData.min, max: workerData.max, average: workerData.average}); } } function xScaleIndicatorRange(sampleIndex) { return (sampleIndex / 1000) / secondsPerPixel; } let graphWidth = (graphEndTime - graphStartTime) / secondsPerPixel; let size = new WI.Size(graphWidth, CPUTimelineView.indicatorViewHeight); this._cpuUsageView.updateMainThreadIndicator(this._statisticsData.samples, size, visibleEndTime, xScaleIndicatorRange); this._layoutEnergyChart(average, visibleDuration); this._updateGraphOverlay(); } // Private _layoutBreakdownChart() { let {samples, samplesScript, samplesLayout, samplesPaint, samplesStyle, samplesIdle} = this._statisticsData; let nonIdleSamplesCount = samples.length - samplesIdle; if (!nonIdleSamplesCount) { this._breakdownChart.clear(); this._breakdownChart.needsLayout(); this._clearBreakdownLegend(); return; } let percentScript = samplesScript / nonIdleSamplesCount; let percentLayout = samplesLayout / nonIdleSamplesCount; let percentPaint = samplesPaint / nonIdleSamplesCount; let percentStyle = samplesStyle / nonIdleSamplesCount; this._breakdownLegendScriptElement.textContent = `${Number.percentageString(percentScript)} (${samplesScript})`; this._breakdownLegendLayoutElement.textContent = `${Number.percentageString(percentLayout)} (${samplesLayout})`; this._breakdownLegendPaintElement.textContent = `${Number.percentageString(percentPaint)} (${samplesPaint})`; this._breakdownLegendStyleElement.textContent = `${Number.percentageString(percentStyle)} (${samplesStyle})`; this._breakdownChart.values = [percentScript * 100, percentLayout * 100, percentPaint * 100, percentStyle * 100]; this._breakdownChart.needsLayout(); let centerElement = this._breakdownChart.centerElement; let samplesElement = centerElement.firstChild; if (!samplesElement) { samplesElement = centerElement.appendChild(document.createElement("div")); samplesElement.classList.add("samples"); samplesElement.title = WI.UIString("Time spent on the main thread"); } let millisecondsStringNoDecimal = WI.UIString("%.0fms").format(nonIdleSamplesCount); samplesElement.textContent = millisecondsStringNoDecimal; } _layoutStatisticsAndSources() { this._layoutStatisticsSection(); this._layoutSourcesSection(); } _layoutStatisticsSection() { let statistics = this._statisticsData; this._clearStatistics(); this._networkRequestsNumberElement.textContent = statistics.networkRequests; this._scriptEntriesNumberElement.textContent = statistics.scriptEntries; let createFilterElement = (type, name) => { let span = document.createElement("span"); span.className = "filter"; span.textContent = name; span.addEventListener("mouseup", (event) => { if (span.classList.contains("active")) this._removeSourcesFilter(type, name); else this._addSourcesFilter(type, name); this._layoutStatisticsAndSources(); }); span.classList.toggle("active", this._sourcesFilter[type].has(name)); return span; }; let expandAllSections = () => { this._sectionLimit = Infinity; this._layoutStatisticsAndSources(); }; function createEllipsisElement() { let span = document.createElement("span"); span.className = "show-more"; span.role = "button"; span.textContent = ellipsis; span.addEventListener("click", (event) => { expandAllSections(); }); return span; } // Sort a Map of key => count values in descending order. function sortMapByEntryCount(map) { let entries = Array.from(map); entries.sort((entryA, entryB) => entryB[1] - entryA[1]); return new Map(entries); } if (statistics.timerTypes.size) { let i = 0; let sorted = sortMapByEntryCount(statistics.timerTypes); for (let [timerType, count] of sorted) { let headerValue = i === 0 ? WI.UIString("Timers:") : ""; let timerTypeElement = createFilterElement("timer", timerType); this._insertTableRow(this._statisticsTable, this._statisticsRows, {headerValue, numberValue: count, labelValue: timerTypeElement}); if (++i === this._sectionLimit && sorted.size > this._sectionLimit) { this._insertTableRow(this._statisticsTable, this._statisticsRows, {labelValue: createEllipsisElement()}); break; } } } if (statistics.eventTypes.size) { let i = 0; let sorted = sortMapByEntryCount(statistics.eventTypes); for (let [eventType, count] of sorted) { let headerValue = i === 0 ? WI.UIString("Events:") : ""; let eventTypeElement = createFilterElement("event", eventType); this._insertTableRow(this._statisticsTable, this._statisticsRows, {headerValue, numberValue: count, labelValue: eventTypeElement}); if (++i === this._sectionLimit && sorted.size > this._sectionLimit) { this._insertTableRow(this._statisticsTable, this._statisticsRows, {labelValue: createEllipsisElement()}); break; } } } if (statistics.observerTypes.size) { let i = 0; let sorted = sortMapByEntryCount(statistics.observerTypes); for (let [observerType, count] of sorted) { let headerValue = i === 0 ? WI.UIString("Observers:") : ""; let observerTypeElement = createFilterElement("observer", observerType); this._insertTableRow(this._statisticsTable, this._statisticsRows, {headerValue, numberValue: count, labelValue: observerTypeElement}); if (++i === this._sectionLimit && sorted.size > this._sectionLimit) { this._insertTableRow(this._statisticsTable, this._statisticsRows, {labelValue: createEllipsisElement()}); break; } } } } _layoutSourcesSection() { let statistics = this._statisticsData; this._clearSources(); const unknownLocationKey = "unknown"; function keyForSourceCodeLocation(sourceCodeLocation) { if (!sourceCodeLocation) return unknownLocationKey; return sourceCodeLocation.sourceCode.url + ":" + sourceCodeLocation.lineNumber + ":" + sourceCodeLocation.columnNumber; } function labelForLocation(key, sourceCodeLocation, functionName) { if (key === unknownLocationKey) { let span = document.createElement("span"); span.className = "unknown"; span.textContent = WI.UIString("Unknown Location"); return span; } const options = { nameStyle: WI.SourceCodeLocation.NameStyle.Short, columnStyle: WI.SourceCodeLocation.ColumnStyle.Shown, dontFloat: true, ignoreNetworkTab: true, ignoreSearchTab: true, }; return WI.createSourceCodeLocationLink(sourceCodeLocation, options); } let timerFilters = this._sourcesFilter.timer; let eventFilters = this._sourcesFilter.event; let observerFilters = this._sourcesFilter.observer; let hasFilters = (timerFilters.size || eventFilters.size || observerFilters.size); let sectionLimit = this._sectionLimit; if (isFinite(sectionLimit) && hasFilters) sectionLimit = CPUTimelineView.defaultSectionLimit * 2; let expandAllSections = () => { this._sectionLimit = Infinity; this._layoutStatisticsAndSources(); }; function createEllipsisElement() { let span = document.createElement("span"); span.className = "show-more"; span.role = "button"; span.textContent = ellipsis; span.addEventListener("click", (event) => { expandAllSections(); }); return span; } let timerMap = new Map; let eventHandlerMap = new Map; let observerCallbackMap = new Map; let seenTimers = new Set; if (!hasFilters || timerFilters.size) { // Aggregate timers on the location where the timers were installed. // For repeating timers, this includes the total counts the interval fired in the selected time range. for (let record of statistics.timerInstallationRecords) { if (timerFilters.size) { if (record.eventType === WI.ScriptTimelineRecord.EventType.AnimationFrameRequested && !timerFilters.has("requestAnimationFrame")) continue; if (record.eventType === WI.ScriptTimelineRecord.EventType.TimerInstalled && !timerFilters.has("setTimeout")) continue; } let callFrame = record.initiatorCallFrame; let sourceCodeLocation = callFrame ? callFrame.sourceCodeLocation : record.sourceCodeLocation; let functionName = callFrame ? callFrame.functionName : ""; let key = keyForSourceCodeLocation(sourceCodeLocation); let entry = timerMap.getOrInitialize(key, {sourceCodeLocation, functionName, count: 0, repeating: false}); if (record.details) { let timerIdentifier = record.details.timerId; let repeatingEntry = statistics.repeatingTimers.get(timerIdentifier); let count = repeatingEntry ? repeatingEntry.count : 1; entry.count += count; if (record.details.repeating) entry.repeating = true; seenTimers.add(timerIdentifier); } else entry.count += 1; } // Aggregate repeating timers where we did not see the installation in the selected time range. // This will use the source code location of where the timer fired, which is better than nothing. if (!hasFilters || timerFilters.has("setTimeout")) { for (let [timerId, repeatingEntry] of statistics.repeatingTimers) { if (seenTimers.has(timerId)) continue; // FIXME: Web Inspector: CPU Usage Timeline - better resolution of installation source for repeated timers // We could have a map of all repeating timer installations in the whole recording // so that we can provide a function name for these repeating timers lacking an installation point. let sourceCodeLocation = repeatingEntry.record.sourceCodeLocation; let key = keyForSourceCodeLocation(sourceCodeLocation); let entry = timerMap.getOrInitialize(key, {sourceCodeLocation, count: 0, repeating: false}); entry.count += repeatingEntry.count; entry.repeating = true; } } } if (!hasFilters || eventFilters.size) { for (let record of statistics.eventHandlerRecords) { if (eventFilters.size && !eventFilters.has(record.details)) continue; let sourceCodeLocation = record.sourceCodeLocation; let key = keyForSourceCodeLocation(sourceCodeLocation); let entry = eventHandlerMap.getOrInitialize(key, {sourceCodeLocation, count: 0}); entry.count += 1; } } if (!hasFilters || observerFilters.size) { for (let record of statistics.observerCallbackRecords) { if (observerFilters.size && !observerFilters.has(record.details)) continue; let sourceCodeLocation = record.sourceCodeLocation; let key = keyForSourceCodeLocation(record.sourceCodeLocation); let entry = observerCallbackMap.getOrInitialize(key, {sourceCodeLocation, count: 0}); entry.count += 1; } } const headerValue = ""; // Sort a Map of key => {count} objects in descending order. function sortMapByEntryCountProperty(map) { let entries = Array.from(map); entries.sort((entryA, entryB) => entryB[1].count - entryA[1].count); return new Map(entries); } if (timerMap.size) { let i = 0; let sorted = sortMapByEntryCountProperty(timerMap); for (let [key, entry] of sorted) { let numberValue = entry.repeating ? WI.UIString("~%s", "Approximate Number", "Approximate count of events").format(entry.count) : entry.count; let sourceCodeLocation = entry.callFrame ? entry.callFrame.sourceCodeLocation : entry.sourceCodeLocation; let labelValue = labelForLocation(key, sourceCodeLocation); let followingRow = this._eventHandlersRow; let row; if (i === 0) { row = this._timerInstallationsRow; this._timerInstallationsNumberElement.textContent = numberValue; this._timerInstallationsLabelElement.append(labelValue); } else row = this._insertTableRow(this._sourcesTable, this._sourcesRows, {headerValue, numberValue, labelValue, followingRow}); if (entry.functionName) row.querySelector(".label").append(` ${enDash} ${entry.functionName}`); if (++i === sectionLimit && sorted.size > sectionLimit) { this._insertTableRow(this._sourcesTable, this._sourcesRows, {labelValue: createEllipsisElement(), followingRow}); break; } } } if (eventHandlerMap.size) { let i = 0; let sorted = sortMapByEntryCountProperty(eventHandlerMap); for (let [key, entry] of sorted) { let numberValue = entry.count; let labelValue = labelForLocation(key, entry.sourceCodeLocation); let followingRow = this._observerHandlersRow; if (i === 0) { this._eventHandlersNumberElement.textContent = numberValue; this._eventHandlersLabelElement.append(labelValue); } else this._insertTableRow(this._sourcesTable, this._sourcesRows, {headerValue, numberValue, labelValue, followingRow}); if (++i === sectionLimit && sorted.size > sectionLimit) { this._insertTableRow(this._sourcesTable, this._sourcesRows, {labelValue: createEllipsisElement(), followingRow}); break; } } } if (observerCallbackMap.size) { let i = 0; let sorted = sortMapByEntryCountProperty(observerCallbackMap); for (let [key, entry] of sorted) { let numberValue = entry.count; let labelValue = labelForLocation(key, entry.sourceCodeLocation); if (i === 0) { this._observerHandlersNumberElement.textContent = numberValue; this._observerHandlersLabelElement.append(labelValue); } else this._insertTableRow(this._sourcesTable, this._sourcesRows, {headerValue, numberValue, labelValue}); if (++i === sectionLimit && sorted.size > sectionLimit) { this._insertTableRow(this._sourcesTable, this._sourcesRows, {labelValue: createEllipsisElement()}); break; } } } } _layoutEnergyChart(average, visibleDuration) { // The lower the bias value [0..1], the more it increases the skew towards rangeHigh. function mapWithBias(value, rangeLow, rangeHigh, outputRangeLow, outputRangeHigh, bias) { console.assert(value >= rangeLow && value <= rangeHigh, "value was not in range.", value); let percentInRange = (value - rangeLow) / (rangeHigh - rangeLow); let skewedPercent = Math.pow(percentInRange, bias); let valueInOutputRange = (skewedPercent * (outputRangeHigh - outputRangeLow)) + outputRangeLow; return valueInOutputRange; } this._clearEnergyImpactText(); if (average <= CPUTimelineView.lowEnergyThreshold) { // Low. (<=3% CPU, mapped to 0-10) this._energyImpactLabelElement.textContent = WI.UIString("Low", "Low @ Timeline Energy Impact", "Energy Impact: Low"); this._energyImpactLabelElement.classList.add("low"); this._energyChart.value = mapWithBias(average, 0, CPUTimelineView.lowEnergyThreshold, 0, CPUTimelineView.lowEnergyGraphBoundary, 0.85); } else if (average <= CPUTimelineView. mediumEnergyThreshold) { // Medium (3%-30% CPU, mapped to 10-70) this._energyImpactLabelElement.textContent = WI.UIString("Medium", "Medium @ Timeline Energy Impact", "Energy Impact: Medium") this._energyImpactLabelElement.classList.add("medium"); this._energyChart.value = mapWithBias(average, CPUTimelineView.lowEnergyThreshold, CPUTimelineView.mediumEnergyThreshold, CPUTimelineView.lowEnergyGraphBoundary, CPUTimelineView.mediumEnergyGraphBoundary, 0.6); } else if (average < CPUTimelineView. highEnergyThreshold) { // High. (30%-100% CPU, mapped to 70-100) this._energyImpactLabelElement.textContent = WI.UIString("High", "High @ Timeline Energy Impact", "Energy Impact: High") this._energyImpactLabelElement.classList.add("high"); this._energyChart.value = mapWithBias(average, CPUTimelineView.mediumEnergyThreshold, CPUTimelineView.highEnergyThreshold, CPUTimelineView.mediumEnergyGraphBoundary, CPUTimelineView.highEnergyGraphBoundary, 0.9); } else { // Very High. (>100% CPU, mapped to 100) this._energyImpactLabelElement.textContent = WI.UIString("Very High", "Very High @ Timeline Energy Impact", "Energy Impact: Very High") this._energyImpactLabelElement.classList.add("high"); this._energyChart.value = 100; } this._energyChart.needsLayout(); this._energyImpactNumberElement.textContent = WI.UIString("Average CPU: %s").format(Number.percentageString(average / 100)); if (visibleDuration < 5) this._energyImpactDurationElement.textContent = WI.UIString("Duration: Short"); else { let durationDisplayString = Math.floor(visibleDuration); this._energyImpactDurationElement.textContent = WI.UIString("Duration: %ss", "The duration of the Timeline recording in seconds (s).").format(durationDisplayString); } } _computeStatisticsData(startTime, endTime) { // Compute per-millisecond samples of what the main thread was doing. // We construct an array for every millisecond between the start and end time // and mark each millisecond with the best representation of the work that // was being done at that time. We start by populating the samples with // all of the script periods and then override with layout and rendering // samples. This means a forced layout would be counted as a layout: // // Initial: [ ------, ------, ------, ------, ------ ] // Script Samples: [ ------, Script, Script, Script, ------ ] // Layout Samples: [ ------, Script, Layout, Script, ------ ] // // The undefined samples are considered Idle, but in actuality WebKit // may have been doing some work (such as hit testing / inspector protocol) // that is not included it in generic Timeline data. This just works with // with the data available to the frontend and is quite accurate for most // Main Thread activity. function incrementTypeCount(map, key) { let entry = map.get(key); if (entry) map.set(key, entry + 1); else map.set(key, 1); } let timerInstallationRecords = []; let eventHandlerRecords = []; let observerCallbackRecords = []; let scriptEntries = 0; let timerTypes = new Map; let eventTypes = new Map; let observerTypes = new Map; let repeatingTimers = new Map; let possibleRepeatingTimers = new Set; let scriptTimeline = this._recording.timelineForRecordType(WI.TimelineRecord.Type.Script); let scriptRecords = scriptTimeline ? scriptTimeline.recordsInTimeRange(startTime, endTime, {includeRecordBeforeStart: true}) : []; scriptRecords = scriptRecords.filter((record) => { // Return true for event types that define script entries/exits. // Return false for events with no time ranges or if they are contained in other events. switch (record.eventType) { case WI.ScriptTimelineRecord.EventType.ScriptEvaluated: case WI.ScriptTimelineRecord.EventType.APIScriptEvaluated: scriptEntries++; return true; case WI.ScriptTimelineRecord.EventType.ObserverCallback: incrementTypeCount(observerTypes, record.details); observerCallbackRecords.push(record); scriptEntries++; return true; case WI.ScriptTimelineRecord.EventType.EventDispatched: incrementTypeCount(eventTypes, record.details); eventHandlerRecords.push(record); scriptEntries++; return true; case WI.ScriptTimelineRecord.EventType.MicrotaskDispatched: // Do not normally count this as a script entry, but they may have a time range // that is not covered by script entry (queueMicrotask). return true; case WI.ScriptTimelineRecord.EventType.TimerFired: incrementTypeCount(timerTypes, "setTimeout"); if (possibleRepeatingTimers.has(record.details)) { let entry = repeatingTimers.get(record.details); if (entry) entry.count += 1; else repeatingTimers.set(record.details, {record, count: 1}); } else possibleRepeatingTimers.add(record.details); scriptEntries++; return true; case WI.ScriptTimelineRecord.EventType.AnimationFrameFired: incrementTypeCount(timerTypes, "requestAnimationFrame"); scriptEntries++; return true; case WI.ScriptTimelineRecord.EventType.AnimationFrameRequested: case WI.ScriptTimelineRecord.EventType.TimerInstalled: // These event types have no time range, or are contained by the others. timerInstallationRecords.push(record); return false; case WI.ScriptTimelineRecord.EventType.AnimationFrameCanceled: case WI.ScriptTimelineRecord.EventType.TimerRemoved: case WI.ScriptTimelineRecord.EventType.ProbeSampleRecorded: case WI.ScriptTimelineRecord.EventType.ConsoleProfileRecorded: case WI.ScriptTimelineRecord.EventType.GarbageCollected: // These event types have no time range, or are contained by the others. return false; default: console.error("Unhandled ScriptTimelineRecord.EventType", record.eventType); return false; } }); let layoutTimeline = this._recording.timelineForRecordType(WI.TimelineRecord.Type.Layout); let layoutRecords = layoutTimeline ? layoutTimeline.recordsInTimeRange(startTime, endTime, {includeRecordBeforeStart: true}) : []; layoutRecords = layoutRecords.filter((record) => { switch (record.eventType) { case WI.LayoutTimelineRecord.EventType.RecalculateStyles: case WI.LayoutTimelineRecord.EventType.ForcedLayout: case WI.LayoutTimelineRecord.EventType.Layout: case WI.LayoutTimelineRecord.EventType.Paint: case WI.LayoutTimelineRecord.EventType.Composite: // These event types define layout and rendering entry/exits. return true; case WI.LayoutTimelineRecord.EventType.InvalidateStyles: case WI.LayoutTimelineRecord.EventType.InvalidateLayout: // These event types have no time range. return false; default: console.error("Unhandled LayoutTimelineRecord.EventType", record.eventType); return false; } }); let networkTimeline = this._recording.timelineForRecordType(WI.TimelineRecord.Type.Network); let networkRecords = networkTimeline ? networkTimeline.recordsInTimeRange(startTime, endTime) : []; let networkRequests = networkRecords.length; let millisecondStartTime = Math.round(startTime * 1000); let millisecondEndTime = Math.round(endTime * 1000); let millisecondDuration = millisecondEndTime - millisecondStartTime; let samples = new Array(millisecondDuration); function markRecordEntries(records, callback) { for (let record of records) { let recordStart = Math.round(record.startTime * 1000); let recordEnd = Math.round(record.endTime * 1000); if (recordStart > millisecondEndTime) continue; if (recordEnd < millisecondStartTime) continue; recordStart = Math.max(recordStart, millisecondStartTime); recordEnd = Math.min(recordEnd, millisecondEndTime); let value = callback(record); for (let t = recordStart; t <= recordEnd; ++t) samples[t - millisecondStartTime] = value; } } markRecordEntries(scriptRecords, (record) => { return CPUTimelineView.SampleType.JavaScript; }); markRecordEntries(layoutRecords, (record) => { switch (record.eventType) { case WI.LayoutTimelineRecord.EventType.RecalculateStyles: return CPUTimelineView.SampleType.Style; case WI.LayoutTimelineRecord.EventType.ForcedLayout: case WI.LayoutTimelineRecord.EventType.Layout: return CPUTimelineView.SampleType.Layout; case WI.LayoutTimelineRecord.EventType.Paint: case WI.LayoutTimelineRecord.EventType.Composite: return CPUTimelineView.SampleType.Paint; } }); let samplesIdle = 0; let samplesScript = 0; let samplesLayout = 0; let samplesPaint = 0; let samplesStyle = 0; for (let i = 0; i < samples.length; ++i) { switch (samples[i]) { case undefined: samplesIdle++; break; case CPUTimelineView.SampleType.JavaScript: samplesScript++; break; case CPUTimelineView.SampleType.Layout: samplesLayout++; break; case CPUTimelineView.SampleType.Paint: samplesPaint++; break; case CPUTimelineView.SampleType.Style: samplesStyle++; break; } } return { samples, samplesIdle, samplesScript, samplesLayout, samplesPaint, samplesStyle, scriptEntries, networkRequests, timerTypes, eventTypes, observerTypes, timerInstallationRecords, eventHandlerRecords, observerCallbackRecords, repeatingTimers, }; } _removeWorkerThreadViews() { if (!this._workerViews.length) return; for (let view of this._workerViews) this.removeSubview(view); this._workerViews = []; } _resetSourcesFilters() { if (!this._sourcesFilter) return; this._sourcesFilterRow.hidden = true; this._sourcesFilterLabelElement.removeChildren(); this._timerInstallationsRow.hidden = false; this._eventHandlersRow.hidden = false; this._observerHandlersRow.hidden = false; this._sourcesFilter.timer.clear(); this._sourcesFilter.event.clear(); this._sourcesFilter.observer.clear(); } _addSourcesFilter(type, name) { this._sourcesFilter[type].add(name); this._updateSourcesFilters(); } _removeSourcesFilter(type, name) { this._sourcesFilter[type].delete(name); this._updateSourcesFilters(); } _updateSourcesFilters() { let timerFilters = this._sourcesFilter.timer; let eventFilters = this._sourcesFilter.event; let observerFilters = this._sourcesFilter.observer; if (!timerFilters.size && !eventFilters.size && !observerFilters.size) { this._resetSourcesFilters(); return; } let createActiveFilterElement = (type, name) => { let span = document.createElement("span"); span.className = "filter active"; span.textContent = name; span.addEventListener("mouseup", (event) => { this._removeSourcesFilter(type, name); this._layoutStatisticsAndSources(); }); return span; }; this._sourcesFilterRow.hidden = false; this._sourcesFilterLabelElement.removeChildren(); for (let name of timerFilters) this._sourcesFilterLabelElement.appendChild(createActiveFilterElement("timer", name)); for (let name of eventFilters) this._sourcesFilterLabelElement.appendChild(createActiveFilterElement("event", name)); for (let name of observerFilters) this._sourcesFilterLabelElement.appendChild(createActiveFilterElement("observer", name)); this._timerInstallationsRow.hidden = !timerFilters.size; this._eventHandlersRow.hidden = !eventFilters.size; this._observerHandlersRow.hidden = !observerFilters.size; } _createTableRow(table) { let row = table.appendChild(document.createElement("tr")); let headerCell = row.appendChild(document.createElement("th")); let numberCell = row.appendChild(document.createElement("td")); numberCell.className = "number"; let labelCell = row.appendChild(document.createElement("td")); labelCell.className = "label"; return {row, headerCell, numberCell, labelCell}; } _insertTableRow(table, rowList, {headerValue, numberValue, labelValue, followingRow}) { let {row, headerCell, numberCell, labelCell} = this._createTableRow(table); rowList.push(row); if (followingRow) table.insertBefore(row, followingRow); if (headerValue) headerCell.textContent = headerValue; if (numberValue) numberCell.textContent = numberValue; if (labelValue) labelCell.append(labelValue); return row; } _clearStatistics() { this._networkRequestsNumberElement.textContent = emDash; this._scriptEntriesNumberElement.textContent = emDash; for (let row of this._statisticsRows) row.remove(); this._statisticsRows = []; } _clearSources() { this._timerInstallationsNumberElement.textContent = emDash; this._timerInstallationsLabelElement.textContent = ""; this._eventHandlersNumberElement.textContent = emDash; this._eventHandlersLabelElement.textContent = ""; this._observerHandlersNumberElement.textContent = emDash; this._observerHandlersLabelElement.textContent = ""; for (let row of this._sourcesRows) row.remove(); this._sourcesRows = []; } _clearEnergyImpactText() { this._energyImpactLabelElement.classList.remove("low", "medium", "high"); this._energyImpactLabelElement.textContent = emDash; this._energyImpactNumberElement.textContent = ""; this._energyImpactDurationElement.textContent = ""; } _clearBreakdownLegend() { this._breakdownLegendScriptElement.textContent = emDash; this._breakdownLegendLayoutElement.textContent = emDash; this._breakdownLegendPaintElement.textContent = emDash; this._breakdownLegendStyleElement.textContent = emDash; this._breakdownChart.centerElement.removeChildren(); } _cpuTimelineRecordAdded(event) { let cpuTimelineRecord = event.data.record; console.assert(cpuTimelineRecord instanceof WI.CPUTimelineRecord); if (cpuTimelineRecord.startTime >= this.startTime && cpuTimelineRecord.endTime <= this.endTime) this.needsLayout(); } _graphPositionForMouseEvent(event) { let chartElement = event.target.closest(".area-chart, .stacked-area-chart, .range-chart"); if (!chartElement) return NaN; let rect = chartElement.getBoundingClientRect(); let position = event.pageX - rect.left; if (WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL) return rect.width - position; return position; } _handleIndicatorClick(event) { let clickPosition = this._graphPositionForMouseEvent(event); if (isNaN(clickPosition)) return; let secondsPerPixel = this._timelineRuler.secondsPerPixel; let graphClickTime = clickPosition * secondsPerPixel; let graphStartTime = this.startTime; let clickStartTime = graphStartTime + graphClickTime; let clickEndTime = clickStartTime + secondsPerPixel; // Try at the exact clicked pixel. if (event.target.localName === "rect") { if (this._attemptSelectIndicatatorTimelineRecord(clickStartTime, clickEndTime)) return; console.assert(false, "If the user clicked on a rect there should have been a record in this pixel range"); } // Spiral out 4 pixels each side to try and select a nearby record. for (let i = 1, delta = 0; i <= 4; ++i) { delta += secondsPerPixel; if (this._attemptSelectIndicatatorTimelineRecord(clickStartTime - delta, clickStartTime)) return; if (this._attemptSelectIndicatatorTimelineRecord(clickEndTime, clickEndTime + delta)) return; } } _attemptSelectIndicatatorTimelineRecord(startTime, endTime) { let layoutTimeline = this._recording.timelineForRecordType(WI.TimelineRecord.Type.Layout); let layoutRecords = layoutTimeline ? layoutTimeline.recordsInTimeRange(startTime, endTime, {includeRecordBeforeStart: true}) : []; layoutRecords = layoutRecords.filter((record) => { switch (record.eventType) { case WI.LayoutTimelineRecord.EventType.RecalculateStyles: case WI.LayoutTimelineRecord.EventType.ForcedLayout: case WI.LayoutTimelineRecord.EventType.Layout: case WI.LayoutTimelineRecord.EventType.Paint: case WI.LayoutTimelineRecord.EventType.Composite: return true; case WI.LayoutTimelineRecord.EventType.InvalidateStyles: case WI.LayoutTimelineRecord.EventType.InvalidateLayout: return false; default: console.error("Unhandled LayoutTimelineRecord.EventType", record.eventType); return false; } }); if (layoutRecords.length) { this._selectTimelineRecord(layoutRecords[0]); return true; } let scriptTimeline = this._recording.timelineForRecordType(WI.TimelineRecord.Type.Script); let scriptRecords = scriptTimeline ? scriptTimeline.recordsInTimeRange(startTime, endTime, {includeRecordBeforeStart: true}) : []; scriptRecords = scriptRecords.filter((record) => { switch (record.eventType) { case WI.ScriptTimelineRecord.EventType.ScriptEvaluated: case WI.ScriptTimelineRecord.EventType.APIScriptEvaluated: case WI.ScriptTimelineRecord.EventType.ObserverCallback: case WI.ScriptTimelineRecord.EventType.EventDispatched: case WI.ScriptTimelineRecord.EventType.MicrotaskDispatched: case WI.ScriptTimelineRecord.EventType.TimerFired: case WI.ScriptTimelineRecord.EventType.AnimationFrameFired: return true; case WI.ScriptTimelineRecord.EventType.AnimationFrameRequested: case WI.ScriptTimelineRecord.EventType.AnimationFrameCanceled: case WI.ScriptTimelineRecord.EventType.TimerInstalled: case WI.ScriptTimelineRecord.EventType.TimerRemoved: case WI.ScriptTimelineRecord.EventType.ProbeSampleRecorded: case WI.ScriptTimelineRecord.EventType.ConsoleProfileRecorded: case WI.ScriptTimelineRecord.EventType.GarbageCollected: return false; default: console.error("Unhandled ScriptTimelineRecord.EventType", record.eventType); return false; } }); if (scriptRecords.length) { this._selectTimelineRecord(scriptRecords[0]); return true; } return false; } _selectTimelineRecord(record) { this.dispatchEventToListeners(WI.TimelineView.Event.RecordWasSelected, {record}); } _handleGraphClick(event) { let mousePosition = this._graphPositionForMouseEvent(event); if (isNaN(mousePosition)) return; this._stickingOverlay = !this._stickingOverlay; if (!this._stickingOverlay) this._handleGraphMouseMove(event); } _handleGraphMouseMove(event) { let mousePosition = this._graphPositionForMouseEvent(event); if (isNaN(mousePosition)) { this._hideGraphOverlay(); this.dispatchEventToListeners(WI.TimelineView.Event.ScannerHide); return; } let secondsPerPixel = this._timelineRuler.secondsPerPixel; let time = this.startTime + (mousePosition * secondsPerPixel); if (!this._stickingOverlay) this._showGraphOverlayNearTo(time); this.dispatchEventToListeners(WI.TimelineView.Event.ScannerShow, {time}); } _showGraphOverlayNearTo(time) { let nearestRecord = null; let nearestDistance = Infinity; // Find the nearest record to the time. for (let record of this._visibleRecordsInLayout) { let distance = Math.abs(time - record.timestamp); if (distance < nearestDistance) { nearestRecord = record; nearestDistance = distance; } } if (!nearestRecord) { this._hideGraphOverlay(); return; } let bestTime = nearestRecord.timestamp; // Snap to a discontinuity if closer. for (let {startTime, endTime} of this._discontinuitiesInLayout) { let distance = Math.abs(time - startTime); if (distance < nearestDistance) { nearestDistance = distance; bestTime = startTime; } distance = Math.abs(time - endTime); if (distance < nearestDistance) { nearestDistance = distance; bestTime = endTime; } } // Snap to end time if closer. let visibleEndTime = Math.min(this.endTime, this.currentTime); let distance = Math.abs(time - visibleEndTime); if (distance < nearestDistance) { nearestDistance = distance; bestTime = visibleEndTime; } let graphStartTime = this.startTime; let adjustedTime = Number.constrain(bestTime, graphStartTime, visibleEndTime); this._showGraphOverlay(nearestRecord, adjustedTime); } _updateGraphOverlay() { if (!this._overlayRecord) return; this._showGraphOverlay(this._overlayRecord, this._overlayTime, true); } _showGraphOverlay(record, time, force) { if (!force && record === this._overlayRecord && time === this._overlayTime) return; this._overlayRecord = record; this._overlayTime = time; let secondsPerPixel = this._secondsPerPixelInLayout; let graphStartTime = this.startTime; this._overlayMarker.time = time + (secondsPerPixel / 2); function xScale(time) { return (time - graphStartTime) / secondsPerPixel; } let x = xScale(time); let {mainThreadUsage, workerThreadUsage, webkitThreadUsage, unknownThreadUsage, workersData} = record; function addOverlayPoint(view, graphHeight, layoutMax, value) { if (!value) return; let graphMax = layoutMax * 1.05; function yScale(value) { return graphHeight - ((value / graphMax) * graphHeight); } view.chart.addPointMarker(x, yScale(value)); view.chart.needsLayout(); } this._clearOverlayMarkers(); this._cpuUsageView.updateLegend(record); addOverlayPoint(this._cpuUsageView, CPUTimelineView.cpuUsageViewHeight, this._layoutMax, mainThreadUsage); addOverlayPoint(this._cpuUsageView, CPUTimelineView.cpuUsageViewHeight, this._layoutMax, mainThreadUsage + workerThreadUsage); addOverlayPoint(this._cpuUsageView, CPUTimelineView.cpuUsageViewHeight, this._layoutMax, mainThreadUsage + workerThreadUsage + webkitThreadUsage + unknownThreadUsage); if (this._threadsDetailsElement.open) { this._mainThreadUsageView.updateLegend(mainThreadUsage); addOverlayPoint(this._mainThreadUsageView, CPUTimelineView.threadCPUUsageViewHeight, this._threadLayoutMax, mainThreadUsage); this._webkitThreadUsageView.updateLegend(webkitThreadUsage); addOverlayPoint(this._webkitThreadUsageView, CPUTimelineView.threadCPUUsageViewHeight, this._threadLayoutMax, webkitThreadUsage); this._unknownThreadUsageView.updateLegend(unknownThreadUsage); addOverlayPoint(this._unknownThreadUsageView, CPUTimelineView.threadCPUUsageViewHeight, this._threadLayoutMax, unknownThreadUsage); for (let workerView of this._workerViews) workerView.updateLegend(NaN); if (workersData) { for (let {targetId, usage} of workersData) { let workerView = this._workerViews.find((x) => x.__workerId === targetId); if (workerView) { workerView.updateLegend(usage); addOverlayPoint(workerView, CPUTimelineView.threadCPUUsageViewHeight, this._threadLayoutMax, usage); } } } } } _clearOverlayMarkers() { function clearGraphOverlayElement(view) { view.clearLegend(); view.chart.clearPointMarkers(); view.chart.needsLayout(); } clearGraphOverlayElement(this._cpuUsageView); clearGraphOverlayElement(this._mainThreadUsageView); clearGraphOverlayElement(this._webkitThreadUsageView); clearGraphOverlayElement(this._unknownThreadUsageView); for (let workerView of this._workerViews) clearGraphOverlayElement(workerView); } _hideGraphOverlay() { if (this._stickingOverlay) return; this._overlayRecord = null; this._overlayTime = NaN; this._overlayMarker.time = -1; this._clearOverlayMarkers(); } }; WI.CPUTimelineView.LayoutReason = { Internal: Symbol("cpu-timeline-view-internal-layout"), }; // NOTE: UI follows this order. WI.CPUTimelineView.SampleType = { JavaScript: "sample-type-javascript", Layout: "sample-type-layout", Paint: "sample-type-paint", Style: "sample-type-style", }; WI.CPUTimelineView.ReferencePage = WI.ReferencePage.TimelinesTab.CPUTimeline;