blob: 365421dc1c7cb62228d9060ebf3312ba23f3e94c [file] [log] [blame]
/*
* 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 WI.CPUTimelineView.SampleType.Script:
return WI.UIString("Script");
case WI.CPUTimelineView.SampleType.Layout:
return WI.UIString("Layout");
case WI.CPUTimelineView.SampleType.Paint:
return WI.UIString("Paint");
case WI.CPUTimelineView.SampleType.Style:
return WI.UIString("Style Resolution");
}
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
shown()
{
super.shown();
if (this._timelineRuler)
this._timelineRuler.updateLayout(WI.View.LayoutReason.Resize);
}
closed()
{
console.assert(this.representedObject instanceof WI.Timeline);
this.representedObject.removeEventListener(null, null, 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()
{
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, WI.CPUTimelineView.SampleType.Script);
this._breakdownLegendLayoutElement = appendLegendRow(this._breakdownLegendElement, WI.CPUTimelineView.SampleType.Layout);
this._breakdownLegendPaintElement = appendLegendRow(this._breakdownLegendElement, WI.CPUTimelineView.SampleType.Paint);
this._breakdownLegendStyleElement = appendLegendRow(this._breakdownLegendElement, WI.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;
let visibleEndTime = Math.min(this.endTime, this.currentTime);
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.startTime;
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: <https://webkit.org/b/195351> 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 === 0) {
// Zero. (0% CPU, mapped to 0)
this._energyImpactLabelElement.textContent = WI.UIString("Low");
this._energyImpactLabelElement.classList.add("low");
this._energyChart.value = 0;
} else if (average <= CPUTimelineView.lowEnergyThreshold) {
// Low. (<=3% CPU, mapped to 0-10)
this._energyImpactLabelElement.textContent = WI.UIString("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");
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");
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");
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: %s").format(WI.UIString("Short"));
else {
let durationDisplayString = Math.floor(visibleDuration) + "s";
this._energyImpactDurationElement.textContent = WI.UIString("Duration: %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;
let offset = recordStart - millisecondStartTime;
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 WI.CPUTimelineView.SampleType.Script;
});
markRecordEntries(layoutRecords, (record) => {
switch (record.eventType) {
case WI.LayoutTimelineRecord.EventType.RecalculateStyles:
return WI.CPUTimelineView.SampleType.Style;
case WI.LayoutTimelineRecord.EventType.ForcedLayout:
case WI.LayoutTimelineRecord.EventType.Layout:
return WI.CPUTimelineView.SampleType.Layout;
case WI.LayoutTimelineRecord.EventType.Paint:
case WI.LayoutTimelineRecord.EventType.Composite:
return WI.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 WI.CPUTimelineView.SampleType.Script:
samplesScript++;
break;
case WI.CPUTimelineView.SampleType.Layout:
samplesLayout++;
break;
case WI.CPUTimelineView.SampleType.Paint:
samplesPaint++;
break;
case WI.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 = {
Script: "sample-type-script",
Layout: "sample-type-layout",
Paint: "sample-type-paint",
Style: "sample-type-style",
};