UNPKG

chrome-devtools-frontend

Version:
1,513 lines (1,333 loc) 87.8 kB
/** * Copyright (C) 2013 Google 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: * * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * 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. * * Neither the name of Google Inc. nor the names of its * contributors may be used to endorse or promote products derived from * this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 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 THE COPYRIGHT * OWNER OR 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. */ import * as Common from '../common/common.js'; import * as Host from '../host/host.js'; import * as i18n from '../i18n/i18n.js'; import * as Platform from '../platform/platform.js'; import * as Root from '../root/root.js'; import * as SDK from '../sdk/sdk.js'; // eslint-disable-line no-unused-vars import * as ThemeSupport from '../theme_support/theme_support.js'; import * as TimelineModel from '../timeline_model/timeline_model.js'; // eslint-disable-line no-unused-vars import * as UI from '../ui/ui.js'; import {ChartViewport, ChartViewportDelegate} from './ChartViewport.js'; // eslint-disable-line no-unused-vars import {Calculator, TimelineGrid} from './TimelineGrid.js'; // eslint-disable-line no-unused-vars export const UIStrings = { /** *@description Aria accessible name in Flame Chart of the Performance panel */ flameChart: 'Flame Chart', /** *@description Text for the screen reader to announce a hovered group *@example {Network} PH1 */ sHovered: '{PH1} hovered', /** *@description Text for screen reader to announce a selected group. *@example {Network} PH1 */ sSelected: '{PH1} selected', /** *@description Text for screen reader to announce an expanded group *@example {Network} PH1 */ sExpanded: '{PH1} expanded', /** *@description Text for screen reader to announce a collapsed group *@example {Network} PH1 */ sCollapsed: '{PH1} collapsed', }; const str_ = i18n.i18n.registerUIStrings('perf_ui/FlameChart.js', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); /** * @interface */ export class FlameChartDelegate { /** * @param {number} startTime * @param {number} endTime * @param {boolean} animate */ windowChanged(startTime, endTime, animate) { } /** * @param {number} startTime * @param {number} endTime */ updateRangeSelection(startTime, endTime) { } /** * @param {!FlameChart} flameChart * @param {?Group} group */ updateSelectedGroup(flameChart, group) { } } /** * @implements {Calculator} * @implements {ChartViewportDelegate} */ export class FlameChart extends UI.Widget.VBox { /** * @param {!FlameChartDataProvider} dataProvider * @param {!FlameChartDelegate} flameChartDelegate * @param {!Common.Settings.Setting<?>=} groupExpansionSetting */ constructor(dataProvider, flameChartDelegate, groupExpansionSetting) { super(true); this.registerRequiredCSS('perf_ui/flameChart.css', {enableLegacyPatching: true}); this.contentElement.classList.add('flame-chart-main-pane'); this._groupExpansionSetting = groupExpansionSetting; this._groupExpansionState = groupExpansionSetting && groupExpansionSetting.get() || {}; this._flameChartDelegate = flameChartDelegate; this._useWebGL = Root.Runtime.experiments.isEnabled('timelineWebGL'); this._chartViewport = new ChartViewport(this); this._chartViewport.show(this.contentElement); this._dataProvider = dataProvider; this._candyStripeCanvas = /** @type {!HTMLCanvasElement} */ (document.createElement('canvas')); this._createCandyStripePattern(); this._viewportElement = this._chartViewport.viewportElement; if (this._useWebGL) { this._canvasGL = /** @type {!HTMLCanvasElement} */ (this._viewportElement.createChild('canvas', 'fill')); this._initWebGL(); } /** @type {!HTMLCanvasElement} */ this._canvas = /** @type {!HTMLCanvasElement} */ (this._viewportElement.createChild('canvas', 'fill')); this._canvas.tabIndex = 0; UI.ARIAUtils.setAccessibleName(this._canvas, i18nString(UIStrings.flameChart)); UI.ARIAUtils.markAsTree(this._canvas); this.setDefaultFocusedElement(this._canvas); this._canvas.classList.add('flame-chart-canvas'); this._canvas.addEventListener('mousemove', this._onMouseMove.bind(this), false); this._canvas.addEventListener('mouseout', this._onMouseOut.bind(this), false); this._canvas.addEventListener('click', this._onClick.bind(this), false); this._canvas.addEventListener('keydown', this._onKeyDown.bind(this), false); this._entryInfo = this._viewportElement.createChild('div', 'flame-chart-entry-info'); this._markerHighlighElement = this._viewportElement.createChild('div', 'flame-chart-marker-highlight-element'); this._highlightElement = this._viewportElement.createChild('div', 'flame-chart-highlight-element'); this._selectedElement = this._viewportElement.createChild('div', 'flame-chart-selected-element'); this._canvas.addEventListener('focus', () => { this._selectedElement.classList.remove('flame-chart-unfocused-selected-element'); this.dispatchEventToListeners(Events.CanvasFocused); }, false); this._canvas.addEventListener('blur', () => { this._selectedElement.classList.add('flame-chart-unfocused-selected-element'); }, false); UI.UIUtils.installDragHandle( this._viewportElement, this._startDragging.bind(this), this._dragging.bind(this), this._endDragging.bind(this), null); this._rulerEnabled = true; this._rangeSelectionStart = 0; this._rangeSelectionEnd = 0; this._barHeight = 17; this._textBaseline = 5; this._textPadding = 5; this._markerRadius = 6; this._chartViewport.setWindowTimes( dataProvider.minimumBoundary(), dataProvider.minimumBoundary() + dataProvider.totalTime()); /** @const */ this._headerLeftPadding = 6; /** @const */ this._arrowSide = 8; /** @const */ this._expansionArrowIndent = this._headerLeftPadding + this._arrowSide / 2; /** @const */ this._headerLabelXPadding = 3; /** @const */ this._headerLabelYPadding = 2; this._highlightedMarkerIndex = -1; this._highlightedEntryIndex = -1; this._selectedEntryIndex = -1; this._rawTimelineDataLength = 0; /** @type {!Map<string, !Map<string,number>>} */ this._textWidth = new Map(); /** @type {!Map<number, !{x: number, width: number}>} */ this._markerPositions = new Map(); this._lastMouseOffsetX = 0; this._selectedGroup = -1; // Keyboard focused group is used to navigate groups irrespective of whether they are selectable or not this._keyboardFocusedGroup = -1; this._selectedGroupBackroundColor = ThemeSupport.ThemeSupport.instance().patchColorText( Colors.SelectedGroupBackground, ThemeSupport.ThemeSupport.ColorUsage.Background); this._selectedGroupBorderColor = ThemeSupport.ThemeSupport.instance().patchColorText( Colors.SelectedGroupBorder, ThemeSupport.ThemeSupport.ColorUsage.Background); /** @type {number} */ this._offsetWidth; /** @type {number} */ this._offsetHeight; /** @type {!HTMLCanvasElement} */ this._canvasGL; /** @type {number} */ this._dragStartX; /** @type {number} */ this._dragStartY; /** @type {number} */ this._lastMouseOffsetX; /** @type {number} */ this._lastMouseOffsetY; /** @type {number} */ this._minimumBoundary; } /** * @override */ willHide() { this.hideHighlight(); } /** * @param {number} value */ setBarHeight(value) { this._barHeight = value; } /** * @param {number} value */ setTextBaseline(value) { this._textBaseline = value; } /** * @param {number} value */ setTextPadding(value) { this._textPadding = value; } /** * @param {boolean} enable */ enableRuler(enable) { this._rulerEnabled = enable; } alwaysShowVerticalScroll() { this._chartViewport.alwaysShowVerticalScroll(); } disableRangeSelection() { this._chartViewport.disableRangeSelection(); } /** * @param {number} entryIndex */ highlightEntry(entryIndex) { if (this._highlightedEntryIndex === entryIndex) { return; } if (!this._dataProvider.entryColor(entryIndex)) { return; } this._highlightedEntryIndex = entryIndex; this._updateElementPosition(this._highlightElement, this._highlightedEntryIndex); this.dispatchEventToListeners(Events.EntryHighlighted, entryIndex); } hideHighlight() { this._entryInfo.removeChildren(); this._highlightedEntryIndex = -1; this._updateElementPosition(this._highlightElement, this._highlightedEntryIndex); this.dispatchEventToListeners(Events.EntryHighlighted, -1); } _createCandyStripePattern() { // Set the candy stripe pattern to 17px so it repeats well. const size = 17; this._candyStripeCanvas.width = size; this._candyStripeCanvas.height = size; const ctx = this._candyStripeCanvas.getContext('2d'); if (!ctx) { return; } // Rotate the stripe by 45deg to the right. ctx.translate(size * 0.5, size * 0.5); ctx.rotate(Math.PI * 0.25); ctx.translate(-size * 0.5, -size * 0.5); ctx.fillStyle = 'rgba(255, 0, 0, 0.4)'; for (let x = -size; x < size * 2; x += 3) { ctx.fillRect(x, -size, 1, size * 3); } } _resetCanvas() { const ratio = window.devicePixelRatio; const width = Math.round(this._offsetWidth * ratio); const height = Math.round(this._offsetHeight * ratio); this._canvas.width = width; this._canvas.height = height; this._canvas.style.width = `${width / ratio}px`; this._canvas.style.height = `${height / ratio}px`; if (this._useWebGL) { this._canvasGL.width = width; this._canvasGL.height = height; this._canvasGL.style.width = `${width / ratio}px`; this._canvasGL.style.height = `${height / ratio}px`; } } /** * @override * @param {number} startTime * @param {number} endTime * @param {boolean} animate */ windowChanged(startTime, endTime, animate) { this._flameChartDelegate.windowChanged(startTime, endTime, animate); } /** * @override * @param {number} startTime * @param {number} endTime */ updateRangeSelection(startTime, endTime) { this._flameChartDelegate.updateRangeSelection(startTime, endTime); } /** * @override * @param {number} width * @param {number} height */ setSize(width, height) { this._offsetWidth = width; this._offsetHeight = height; } /** * @param {!MouseEvent} event */ _startDragging(event) { this.hideHighlight(); this._maxDragOffset = 0; this._dragStartX = event.pageX; this._dragStartY = event.pageY; return true; } /** * @param {!MouseEvent} event */ _dragging(event) { const dx = event.pageX - this._dragStartX; const dy = event.pageY - this._dragStartY; this._maxDragOffset = Math.max(this._maxDragOffset, Math.sqrt(dx * dx + dy * dy)); } /** * @param {!MouseEvent} event */ _endDragging(event) { this._updateHighlight(); } /** * @return {?TimelineData} */ _timelineData() { if (!this._dataProvider) { return null; } const timelineData = this._dataProvider.timelineData(); if (timelineData !== this._rawTimelineData || (timelineData && timelineData.entryStartTimes.length !== this._rawTimelineDataLength)) { this._processTimelineData(timelineData); } return this._rawTimelineData || null; } /** * @param {number} entryIndex */ _revealEntry(entryIndex) { const timelineData = this._timelineData(); if (!timelineData) { return; } const timeLeft = this._chartViewport.windowLeftTime(); const timeRight = this._chartViewport.windowRightTime(); const entryStartTime = timelineData.entryStartTimes[entryIndex]; const entryTotalTime = timelineData.entryTotalTimes[entryIndex]; const entryEndTime = entryStartTime + entryTotalTime; let minEntryTimeWindow = Math.min(entryTotalTime, timeRight - timeLeft); const level = timelineData.entryLevels[entryIndex]; this._chartViewport.setScrollOffset(this._levelToOffset(level), this._levelHeight(level)); const minVisibleWidthPx = 30; const futurePixelToTime = (timeRight - timeLeft) / this._offsetWidth; minEntryTimeWindow = Math.max(minEntryTimeWindow, futurePixelToTime * minVisibleWidthPx); if (timeLeft > entryEndTime) { const delta = timeLeft - entryEndTime + minEntryTimeWindow; this.windowChanged(timeLeft - delta, timeRight - delta, /* animate */ true); } else if (timeRight < entryStartTime) { const delta = entryStartTime - timeRight + minEntryTimeWindow; this.windowChanged(timeLeft + delta, timeRight + delta, /* animate */ true); } } /** * @param {number} startTime * @param {number} endTime * @param {boolean=} animate */ setWindowTimes(startTime, endTime, animate) { this._chartViewport.setWindowTimes(startTime, endTime, animate); this._updateHighlight(); } /** * @param {!Event} event */ _onMouseMove(event) { const mouseEvent = /** @type {!MouseEvent} */ (event); this._lastMouseOffsetX = mouseEvent.offsetX; this._lastMouseOffsetY = mouseEvent.offsetY; if (!this._enabled()) { return; } if (this._chartViewport.isDragging()) { return; } if (this._coordinatesToGroupIndex(mouseEvent.offsetX, mouseEvent.offsetY, true /* headerOnly */) >= 0) { this.hideHighlight(); this._viewportElement.style.cursor = 'pointer'; return; } this._updateHighlight(); } _updateHighlight() { const entryIndex = this._coordinatesToEntryIndex(this._lastMouseOffsetX, this._lastMouseOffsetY); if (entryIndex === -1) { this.hideHighlight(); const group = this._coordinatesToGroupIndex(this._lastMouseOffsetX, this._lastMouseOffsetY, false /* headerOnly */); if (group >= 0 && this._rawTimelineData && this._rawTimelineData.groups && this._rawTimelineData.groups[group].selectable) { this._viewportElement.style.cursor = 'pointer'; } else { this._viewportElement.style.cursor = 'default'; } return; } if (this._chartViewport.isDragging()) { return; } this._updatePopover(entryIndex); this._viewportElement.style.cursor = this._dataProvider.canJumpToEntry(entryIndex) ? 'pointer' : 'default'; this.highlightEntry(entryIndex); } _onMouseOut() { this._lastMouseOffsetX = -1; this._lastMouseOffsetY = -1; this.hideHighlight(); } /** * @param {number} entryIndex */ _updatePopover(entryIndex) { if (entryIndex === this._highlightedEntryIndex) { this._updatePopoverOffset(); return; } this._entryInfo.removeChildren(); const popoverElement = this._dataProvider.prepareHighlightedEntryInfo(entryIndex); if (popoverElement) { this._entryInfo.appendChild(popoverElement); this._updatePopoverOffset(); } } _updatePopoverOffset() { const mouseX = this._lastMouseOffsetX; const mouseY = this._lastMouseOffsetY; const parentWidth = this._entryInfo.parentElement ? this._entryInfo.parentElement.clientWidth : 0; const parentHeight = this._entryInfo.parentElement ? this._entryInfo.parentElement.clientHeight : 0; const infoWidth = this._entryInfo.clientWidth; const infoHeight = this._entryInfo.clientHeight; const /** @const */ offsetX = 10; const /** @const */ offsetY = 6; let x; let y; for (let quadrant = 0; quadrant < 4; ++quadrant) { const dx = quadrant & 2 ? -offsetX - infoWidth : offsetX; const dy = quadrant & 1 ? -offsetY - infoHeight : offsetY; x = Platform.NumberUtilities.clamp(mouseX + dx, 0, parentWidth - infoWidth); y = Platform.NumberUtilities.clamp(mouseY + dy, 0, parentHeight - infoHeight); if (x >= mouseX || mouseX >= x + infoWidth || y >= mouseY || mouseY >= y + infoHeight) { break; } } this._entryInfo.style.left = x + 'px'; this._entryInfo.style.top = y + 'px'; } /** * @param {!Event} event */ _onClick(event) { const mouseEvent = /** @type {!MouseEvent} */ (event); this.focus(); // onClick comes after dragStart and dragEnd events. // So if there was drag (mouse move) in the middle of that events // we skip the click. Otherwise we jump to the sources. const /** @const */ clickThreshold = 5; if (this._maxDragOffset > clickThreshold) { return; } this._selectGroup(this._coordinatesToGroupIndex(mouseEvent.offsetX, mouseEvent.offsetY, false /* headerOnly */)); this._toggleGroupExpand( this._coordinatesToGroupIndex(mouseEvent.offsetX, mouseEvent.offsetY, true /* headerOnly */)); const timelineData = this._timelineData(); if (mouseEvent.shiftKey && this._highlightedEntryIndex !== -1 && timelineData) { const start = timelineData.entryStartTimes[this._highlightedEntryIndex]; const end = start + timelineData.entryTotalTimes[this._highlightedEntryIndex]; this._chartViewport.setRangeSelection(start, end); } else { this._chartViewport.onClick(mouseEvent); this.dispatchEventToListeners(Events.EntryInvoked, this._highlightedEntryIndex); } } /** * @param {number} groupIndex */ _selectGroup(groupIndex) { if (groupIndex < 0 || this._selectedGroup === groupIndex) { return; } if (!this._rawTimelineData) { return; } const groups = this._rawTimelineData.groups; if (!groups) { return; } this._keyboardFocusedGroup = groupIndex; this._scrollGroupIntoView(groupIndex); const groupName = groups[groupIndex].name; if (!groups[groupIndex].selectable) { this._deselectAllGroups(); UI.ARIAUtils.alert(i18nString(UIStrings.sHovered, {PH1: groupName}), this._canvas); } else { this._selectedGroup = groupIndex; this._flameChartDelegate.updateSelectedGroup(this, groups[groupIndex]); this._resetCanvas(); this._draw(); UI.ARIAUtils.alert(i18nString(UIStrings.sSelected, {PH1: groupName}), this._canvas); } } _deselectAllGroups() { this._selectedGroup = -1; this._flameChartDelegate.updateSelectedGroup(this, null); this._resetCanvas(); this._draw(); } _deselectAllEntries() { this._selectedEntryIndex = -1; this._resetCanvas(); this._draw(); } /** * @param {number} index */ _isGroupFocused(index) { return index === this._selectedGroup || index === this._keyboardFocusedGroup; } /** * @param {number} index */ _scrollGroupIntoView(index) { if (index < 0) { return; } if (!this._rawTimelineData) { return; } const groups = this._rawTimelineData.groups; const groupOffsets = this._groupOffsets; if (!groupOffsets || !groups) { return; } const groupTop = groupOffsets[index]; let nextOffset = groupOffsets[index + 1]; if (index === groups.length - 1) { nextOffset += groups[index].style.padding; } // For the top group, scroll all the way to the top of the chart // to accommodate the bar with time markers const scrollTop = index === 0 ? 0 : groupTop; const scrollHeight = Math.min(nextOffset - scrollTop, this._chartViewport.chartHeight()); this._chartViewport.setScrollOffset(scrollTop, scrollHeight); } /** * @param {number} groupIndex */ _toggleGroupExpand(groupIndex) { if (groupIndex < 0 || !this._isGroupCollapsible(groupIndex)) { return; } if (!this._rawTimelineData || !this._rawTimelineData.groups) { return; } this._expandGroup(groupIndex, !this._rawTimelineData.groups[groupIndex].expanded /* setExpanded */); } /** * @param {number} groupIndex * @param {boolean=} setExpanded * @param {boolean=} propagatedExpand */ _expandGroup(groupIndex, setExpanded = true, propagatedExpand = false) { if (groupIndex < 0 || !this._isGroupCollapsible(groupIndex)) { return; } if (!this._rawTimelineData) { return; } const groups = this._rawTimelineData.groups; if (!groups) { return; } const group = groups[groupIndex]; group.expanded = setExpanded; this._groupExpansionState[group.name] = group.expanded; if (this._groupExpansionSetting) { this._groupExpansionSetting.set(this._groupExpansionState); } this._updateLevelPositions(); this._updateHighlight(); if (!group.expanded) { const timelineData = this._timelineData(); if (timelineData) { const level = timelineData.entryLevels[this._selectedEntryIndex]; if (this._selectedEntryIndex >= 0 && level >= group.startLevel && (groupIndex >= groups.length - 1 || groups[groupIndex + 1].startLevel > level)) { this._selectedEntryIndex = -1; } } } this._updateHeight(); this._resetCanvas(); this._draw(); this._scrollGroupIntoView(groupIndex); // We only want to read expanded/collapsed state on user inputted expand/collapse if (!propagatedExpand) { const groupName = groups[groupIndex].name; const content = group.expanded ? i18nString(UIStrings.sExpanded, {PH1: groupName}) : i18nString(UIStrings.sCollapsed, {PH1: groupName}); UI.ARIAUtils.alert(content, this._canvas); } } /** * @param {!KeyboardEvent} e */ _onKeyDown(e) { if (!UI.KeyboardShortcut.KeyboardShortcut.hasNoModifiers(e) || !this._timelineData()) { return; } const eventHandled = this._handleSelectionNavigation(e); // Handle keyboard navigation in groups if (!eventHandled && this._rawTimelineData && this._rawTimelineData.groups) { this._handleKeyboardGroupNavigation(e); } } /** * @param {string} eventName * @param {function(!Event):void} onEvent */ bindCanvasEvent(eventName, onEvent) { this._canvas.addEventListener(eventName, onEvent); } /** * @param {!Event} event */ _handleKeyboardGroupNavigation(event) { const keyboardEvent = /** @type {!KeyboardEvent} */ (event); let handled = false; let entrySelected = false; if (keyboardEvent.code === 'ArrowUp') { handled = this._selectPreviousGroup(); } else if (keyboardEvent.code === 'ArrowDown') { handled = this._selectNextGroup(); } else if (keyboardEvent.code === 'ArrowLeft') { if (this._keyboardFocusedGroup >= 0) { this._expandGroup(this._keyboardFocusedGroup, false /* setExpanded */); handled = true; } } else if (keyboardEvent.code === 'ArrowRight') { if (this._keyboardFocusedGroup >= 0) { this._expandGroup(this._keyboardFocusedGroup, true /* setExpanded */); this._selectFirstChild(); handled = true; } } else if (keyboardEvent.key === 'Enter') { entrySelected = this._selectFirstEntryInCurrentGroup(); handled = entrySelected; } if (handled && !entrySelected) { this._deselectAllEntries(); } if (handled) { keyboardEvent.consume(true); } } /** * @return {boolean} */ _selectFirstEntryInCurrentGroup() { if (!this._rawTimelineData) { return false; } const allGroups = this._rawTimelineData.groups; if (this._keyboardFocusedGroup < 0 || !allGroups) { return false; } const group = allGroups[this._keyboardFocusedGroup]; const startLevelInGroup = group.startLevel; // Return if no levels in this group if (startLevelInGroup < 0) { return false; } // Make sure this is the innermost nested group with this startLevel // This is because a parent group also contains levels of all its child groups // So check if the next group has the same level, if it does, user should // go to that child group to select this entry if (this._keyboardFocusedGroup < allGroups.length - 1 && allGroups[this._keyboardFocusedGroup + 1].startLevel === startLevelInGroup) { return false; } if (!this._timelineLevels) { return false; } // Get first (default) entry in startLevel of selected group const firstEntryIndex = this._timelineLevels[startLevelInGroup][0]; this._expandGroup(this._keyboardFocusedGroup, true /* setExpanded */); this.setSelectedEntry(firstEntryIndex); return true; } /** * @return {boolean} */ _selectPreviousGroup() { if (this._keyboardFocusedGroup <= 0) { return false; } const groupIndexToSelect = this._getGroupIndexToSelect(-1 /* offset */); this._selectGroup(groupIndexToSelect); return true; } /** * @return {boolean} */ _selectNextGroup() { if (!this._rawTimelineData || !this._rawTimelineData.groups) { return false; } if (this._keyboardFocusedGroup >= this._rawTimelineData.groups.length - 1) { return false; } const groupIndexToSelect = this._getGroupIndexToSelect(1 /* offset */); this._selectGroup(groupIndexToSelect); return true; } /** * @param {number} offset * @return {number} */ _getGroupIndexToSelect(offset) { if (!this._rawTimelineData || !this._rawTimelineData.groups) { throw new Error('No raw timeline data'); } const allGroups = this._rawTimelineData.groups; let groupIndexToSelect = this._keyboardFocusedGroup; let groupName, groupWithSubNestingLevel; do { groupIndexToSelect += offset; groupName = this._rawTimelineData.groups[groupIndexToSelect].name; groupWithSubNestingLevel = this._keyboardFocusedGroup !== -1 && allGroups[groupIndexToSelect].style.nestingLevel > allGroups[this._keyboardFocusedGroup].style.nestingLevel; } while (groupIndexToSelect > 0 && groupIndexToSelect < allGroups.length - 1 && (!groupName || groupWithSubNestingLevel)); return groupIndexToSelect; } _selectFirstChild() { if (!this._rawTimelineData || !this._rawTimelineData.groups) { return; } const allGroups = this._rawTimelineData.groups; if (this._keyboardFocusedGroup < 0 || this._keyboardFocusedGroup >= allGroups.length - 1) { return; } const groupIndexToSelect = this._keyboardFocusedGroup + 1; if (allGroups[groupIndexToSelect].style.nestingLevel > allGroups[this._keyboardFocusedGroup].style.nestingLevel) { this._selectGroup(groupIndexToSelect); } } /** * @param {!KeyboardEvent} event * @return {boolean} */ _handleSelectionNavigation(event) { if (this._selectedEntryIndex === -1) { return false; } const timelineData = this._timelineData(); if (!timelineData) { return false; } /** * @param {number} time * @param {number} entryIndex * @return {number} */ function timeComparator(time, entryIndex) { if (!timelineData) { throw new Error('No timeline data'); } return time - timelineData.entryStartTimes[entryIndex]; } /** * @param {number} entry1 * @param {number} entry2 * @return {boolean} */ function entriesIntersect(entry1, entry2) { if (!timelineData) { throw new Error('No timeline data'); } const start1 = timelineData.entryStartTimes[entry1]; const start2 = timelineData.entryStartTimes[entry2]; const end1 = start1 + timelineData.entryTotalTimes[entry1]; const end2 = start2 + timelineData.entryTotalTimes[entry2]; return start1 < end2 && start2 < end1; } const keyboardEvent = /** @type {!KeyboardEvent} */ (event); const keys = UI.KeyboardShortcut.Keys; if (keyboardEvent.keyCode === keys.Left.code || keyboardEvent.keyCode === keys.Right.code) { const level = timelineData.entryLevels[this._selectedEntryIndex]; const levelIndexes = this._timelineLevels ? this._timelineLevels[level] : []; let indexOnLevel = levelIndexes.lowerBound(this._selectedEntryIndex); indexOnLevel += keyboardEvent.keyCode === keys.Left.code ? -1 : 1; event.consume(true); if (indexOnLevel >= 0 && indexOnLevel < levelIndexes.length) { this.dispatchEventToListeners(Events.EntrySelected, levelIndexes[indexOnLevel]); } return true; } if (keyboardEvent.keyCode === keys.Up.code || keyboardEvent.keyCode === keys.Down.code) { let level = timelineData.entryLevels[this._selectedEntryIndex]; level += keyboardEvent.keyCode === keys.Up.code ? -1 : 1; if (level < 0 || (this._timelineLevels && level >= this._timelineLevels.length)) { this._deselectAllEntries(); keyboardEvent.consume(true); return true; } const entryTime = timelineData.entryStartTimes[this._selectedEntryIndex] + timelineData.entryTotalTimes[this._selectedEntryIndex] / 2; const levelIndexes = this._timelineLevels ? this._timelineLevels[level] : []; let indexOnLevel = Platform.ArrayUtilities.upperBound(levelIndexes, entryTime, timeComparator) - 1; if (!entriesIntersect(this._selectedEntryIndex, levelIndexes[indexOnLevel])) { ++indexOnLevel; if (indexOnLevel >= levelIndexes.length || !entriesIntersect(this._selectedEntryIndex, levelIndexes[indexOnLevel])) { if (keyboardEvent.code === 'ArrowDown') { return false; } // Stay in the current group and give focus to the parent group instead of entries this._deselectAllEntries(); keyboardEvent.consume(true); return true; } } keyboardEvent.consume(true); this.dispatchEventToListeners(Events.EntrySelected, levelIndexes[indexOnLevel]); return true; } if (event.key === 'Enter') { event.consume(true); this.dispatchEventToListeners(Events.EntryInvoked, this._selectedEntryIndex); return true; } return false; } /** * @param {number} x * @param {number} y * @return {number} */ _coordinatesToEntryIndex(x, y) { if (x < 0 || y < 0) { return -1; } const timelineData = this._timelineData(); if (!timelineData) { return -1; } y += this._chartViewport.scrollOffset(); if (!this._visibleLevelOffsets) { throw new Error('No visible level offsets'); } const cursorLevel = Platform.ArrayUtilities.upperBound(this._visibleLevelOffsets, y, Platform.ArrayUtilities.DEFAULT_COMPARATOR) - 1; if (cursorLevel < 0 || (this._visibleLevels && !this._visibleLevels[cursorLevel])) { return -1; } const offsetFromLevel = y - this._visibleLevelOffsets[cursorLevel]; if (offsetFromLevel > this._levelHeight(cursorLevel)) { return -1; } // Check markers first. for (const [index, pos] of this._markerPositions) { if (timelineData.entryLevels[index] !== cursorLevel) { continue; } if (pos.x <= x && x < pos.x + pos.width) { return /** @type {number} */ (index); } } // Check regular entries. const entryStartTimes = timelineData.entryStartTimes; /** @type {!Array.<number>} */ const entriesOnLevel = this._timelineLevels ? this._timelineLevels[cursorLevel] : []; if (!entriesOnLevel || !entriesOnLevel.length) { return -1; } const cursorTime = this._chartViewport.pixelToTime(x); const indexOnLevel = Math.max( Platform.ArrayUtilities.upperBound( entriesOnLevel, cursorTime, (time, entryIndex) => time - entryStartTimes[entryIndex]) - 1, 0); /** * @this {FlameChart} * @param {number|undefined} entryIndex * @return {boolean} */ function checkEntryHit(entryIndex) { if (entryIndex === undefined) { return false; } if (!timelineData) { return false; } const startTime = entryStartTimes[entryIndex]; const duration = timelineData.entryTotalTimes[entryIndex]; const startX = this._chartViewport.timeToPosition(startTime); const endX = this._chartViewport.timeToPosition(startTime + duration); const barThresholdPx = 3; return startX - barThresholdPx < x && x < endX + barThresholdPx; } let entryIndex = entriesOnLevel[indexOnLevel]; if (checkEntryHit.call(this, entryIndex)) { return entryIndex; } entryIndex = entriesOnLevel[indexOnLevel + 1]; if (checkEntryHit.call(this, entryIndex)) { return entryIndex; } return -1; } /** * @param {number} x * @param {number} y * @param {boolean} headerOnly * @return {number} */ _coordinatesToGroupIndex(x, y, headerOnly) { if (!this._rawTimelineData || !this._rawTimelineData.groups || !this._groupOffsets) { return -1; } if (x < 0 || y < 0) { return -1; } y += this._chartViewport.scrollOffset(); const groups = this._rawTimelineData.groups || []; const group = Platform.ArrayUtilities.upperBound(this._groupOffsets, y, Platform.ArrayUtilities.DEFAULT_COMPARATOR) - 1; if (group < 0 || group >= groups.length) { return -1; } const height = headerOnly ? groups[group].style.height : this._groupOffsets[group + 1] - this._groupOffsets[group]; if (y - this._groupOffsets[group] >= height) { return -1; } if (!headerOnly) { return group; } const context = /** @type {!CanvasRenderingContext2D} */ (this._canvas.getContext('2d')); context.save(); context.font = groups[group].style.font; const right = this._headerLeftPadding + this._labelWidthForGroup(context, groups[group]); context.restore(); if (x > right) { return -1; } return group; } /** * @param {number} x * @return {number} */ _markerIndexAtPosition(x) { const timelineData = this._timelineData(); if (!timelineData) { return -1; } const markers = timelineData.markers; if (!markers) { return -1; } const /** @const */ accurracyOffsetPx = 4; const time = this._chartViewport.pixelToTime(x); const leftTime = this._chartViewport.pixelToTime(x - accurracyOffsetPx); const rightTime = this._chartViewport.pixelToTime(x + accurracyOffsetPx); const left = this._markerIndexBeforeTime(leftTime); let markerIndex = -1; let distance = Infinity; for (let i = left; i < markers.length && markers[i].startTime() < rightTime; i++) { const nextDistance = Math.abs(markers[i].startTime() - time); if (nextDistance < distance) { markerIndex = i; distance = nextDistance; } } return markerIndex; } /** * @param {number} time * @return {number} */ _markerIndexBeforeTime(time) { const timelineData = this._timelineData(); if (!timelineData) { throw new Error('No timeline data'); } const markers = timelineData.markers; if (!markers) { throw new Error('No timeline markers'); } return Platform.ArrayUtilities.lowerBound( timelineData.markers, time, (markerTimestamp, marker) => markerTimestamp - marker.startTime()); } _draw() { const timelineData = this._timelineData(); if (!timelineData) { return; } const visibleLevelOffsets = this._visibleLevelOffsets ? this._visibleLevelOffsets : new Uint32Array(); const width = this._offsetWidth; const height = this._offsetHeight; const context = /** @type {!CanvasRenderingContext2D} */ (this._canvas.getContext('2d')); context.save(); const ratio = window.devicePixelRatio; const top = this._chartViewport.scrollOffset(); context.scale(ratio, ratio); context.fillStyle = 'rgba(0, 0, 0, 0)'; context.fillRect(0, 0, width, height); context.translate(0, -top); const defaultFont = '11px ' + Host.Platform.fontFamily(); context.font = defaultFont; const candyStripePattern = context.createPattern(this._candyStripeCanvas, 'repeat'); const entryTotalTimes = timelineData.entryTotalTimes; const entryStartTimes = timelineData.entryStartTimes; const entryLevels = timelineData.entryLevels; const timeToPixel = this._chartViewport.timeToPixel(); const titleIndices = []; const markerIndices = []; const textPadding = this._textPadding; const minTextWidth = 2 * textPadding + UI.UIUtils.measureTextWidth(context, '…'); const minTextWidthDuration = this._chartViewport.pixelToTimeOffset(minTextWidth); const minVisibleBarLevel = Math.max( Platform.ArrayUtilities.upperBound(visibleLevelOffsets, top, Platform.ArrayUtilities.DEFAULT_COMPARATOR) - 1, 0); this._markerPositions.clear(); let mainThreadTopLevel = -1; // Find the main thread so that we can mark tasks longer than 50ms. if ('groups' in timelineData && Array.isArray(timelineData.groups)) { const mainThread = timelineData.groups.find(group => { if (!group.track) { return false; } return group.track.name === 'CrRendererMain'; }); if (mainThread) { mainThreadTopLevel = mainThread.startLevel; } } /** @type {!Map<string, {indexes: !Array<number>}>} */ const colorBuckets = new Map(); for (let level = minVisibleBarLevel; level < this._dataProvider.maxStackDepth(); ++level) { if (this._levelToOffset(level) > top + height) { break; } if (!this._visibleLevels || !this._visibleLevels[level]) { continue; } if (!this._timelineLevels) { continue; } // Entries are ordered by start time within a level, so find the last visible entry. /** @type {!Array.<number>} */ const levelIndexes = this._timelineLevels[level]; const rightIndexOnLevel = Platform.ArrayUtilities.lowerBound( levelIndexes, this._chartViewport.windowRightTime(), (time, entryIndex) => time - entryStartTimes[entryIndex]) - 1; let lastDrawOffset = Infinity; for (let entryIndexOnLevel = rightIndexOnLevel; entryIndexOnLevel >= 0; --entryIndexOnLevel) { const entryIndex = levelIndexes[entryIndexOnLevel]; const duration = entryTotalTimes[entryIndex]; if (isNaN(duration)) { markerIndices.push(entryIndex); continue; } if (duration >= minTextWidthDuration || (this._forceDecorationCache && this._forceDecorationCache[entryIndex])) { titleIndices.push(entryIndex); } const entryStartTime = entryStartTimes[entryIndex]; const entryOffsetRight = entryStartTime + duration; if (entryOffsetRight <= this._chartViewport.windowLeftTime()) { break; } if (this._useWebGL) { continue; } const barX = this._timeToPositionClipped(entryStartTime); // Check if the entry entirely fits into an already drawn pixel, we can just skip drawing it. if (barX >= lastDrawOffset) { continue; } lastDrawOffset = barX; if (this._entryColorsCache) { const color = this._entryColorsCache[entryIndex]; let bucket = colorBuckets.get(color); if (!bucket) { bucket = {indexes: []}; colorBuckets.set(color, bucket); } bucket.indexes.push(entryIndex); } } } if (this._useWebGL) { this._drawGL(); } else { context.save(); this._forEachGroupInViewport((offset, index, group, isFirst, groupHeight) => { if (this._isGroupFocused(index)) { context.fillStyle = this._selectedGroupBackroundColor; context.fillRect(0, offset, width, groupHeight - group.style.padding); } }); context.restore(); for (const [color, {indexes}] of colorBuckets) { context.beginPath(); for (let i = 0; i < indexes.length; ++i) { const entryIndex = indexes[i]; const duration = entryTotalTimes[entryIndex]; if (isNaN(duration)) { continue; } const entryStartTime = entryStartTimes[entryIndex]; const barX = this._timeToPositionClipped(entryStartTime); const barLevel = entryLevels[entryIndex]; const barHeight = this._levelHeight(barLevel); const barY = this._levelToOffset(barLevel); const barRight = this._timeToPositionClipped(entryStartTime + duration); const barWidth = Math.max(barRight - barX, 1); context.rect(barX, barY, barWidth - 0.4, barHeight - 1); } context.fillStyle = color; context.fill(); // Draw long task regions. context.beginPath(); for (let i = 0; i < indexes.length; ++i) { const entryIndex = indexes[i]; const duration = entryTotalTimes[entryIndex]; const showLongDurations = entryLevels[entryIndex] === mainThreadTopLevel; if (!showLongDurations) { continue; } if (isNaN(duration) || duration < 50) { continue; } const entryStartTime = entryStartTimes[entryIndex]; const barX = this._timeToPositionClipped(entryStartTime + 50); const barLevel = entryLevels[entryIndex]; const barHeight = this._levelHeight(barLevel); const barY = this._levelToOffset(barLevel); const barRight = this._timeToPositionClipped(entryStartTime + duration); const barWidth = Math.max(barRight - barX, 1); context.rect(barX, barY, barWidth - 0.4, barHeight - 1); } if (candyStripePattern) { context.fillStyle = candyStripePattern; context.fill(); } } } context.textBaseline = 'alphabetic'; context.beginPath(); let lastMarkerLevel = -1; let lastMarkerX = -Infinity; // Markers are sorted top to bottom, right to left. for (let m = markerIndices.length - 1; m >= 0; --m) { const entryIndex = markerIndices[m]; const title = this._dataProvider.entryTitle(entryIndex); if (!title) { continue; } const entryStartTime = entryStartTimes[entryIndex]; const level = entryLevels[entryIndex]; if (lastMarkerLevel !== level) { lastMarkerX = -Infinity; } const x = Math.max(this._chartViewport.timeToPosition(entryStartTime), lastMarkerX); const y = this._levelToOffset(level); const h = this._levelHeight(level); const padding = 4; const width = Math.ceil(UI.UIUtils.measureTextWidth(context, title)) + 2 * padding; lastMarkerX = x + width + 1; lastMarkerLevel = level; this._markerPositions.set(entryIndex, {x, width}); context.fillStyle = this._dataProvider.entryColor(entryIndex); context.fillRect(x, y, width, h - 1); context.fillStyle = 'white'; context.fillText(title, x + padding, y + h - this._textBaseline); } context.strokeStyle = 'rgba(0, 0, 0, 0.2)'; context.stroke(); for (let i = 0; i < titleIndices.length; ++i) { const entryIndex = titleIndices[i]; const entryStartTime = entryStartTimes[entryIndex]; const barX = this._timeToPositionClipped(entryStartTime); const barRight = Math.min(this._timeToPositionClipped(entryStartTime + entryTotalTimes[entryIndex]), width) + 1; const barWidth = barRight - barX; const barLevel = entryLevels[entryIndex]; const barY = this._levelToOffset(barLevel); let text = this._dataProvider.entryTitle(entryIndex); if (text && text.length) { context.font = this._dataProvider.entryFont(entryIndex) || defaultFont; text = UI.UIUtils.trimTextMiddle(context, text, barWidth - 2 * textPadding); } const unclippedBarX = this._chartViewport.timeToPosition(entryStartTime); const barHeight = this._levelHeight(barLevel); if (this._dataProvider.decorateEntry( entryIndex, context, text, barX, barY, barWidth, barHeight, unclippedBarX, timeToPixel)) { continue; } if (!text || !text.length) { continue; } context.fillStyle = this._dataProvider.textColor(entryIndex); context.fillText(text, barX + textPadding, barY + barHeight - this._textBaseline); } context.restore(); this._drawGroupHeaders(width, height); this._drawFlowEvents(context, width, height); this._drawMarkers(); const dividersData = TimelineGrid.calculateGridOffsets(this); const navStartTimes = Array.from(this._dataProvider.navStartTimes().values()); let navStartTimeIndex = 0; /** * @param {number} time */ const drawAdjustedTime = time => { if (navStartTimes.length === 0) { return this.formatValue(time, dividersData.precision); } // Track when the time crosses the boundary to the next nav start record, // and when it does, move the nav start array index accordingly. const hasNextNavStartTime = navStartTimes.length > navStartTimeIndex + 1; if (hasNextNavStartTime && time > navStartTimes[navStartTimeIndex + 1].startTime) { navStartTimeIndex++; } // Adjust the time by the nearest nav start marker's value. const nearestMarker = navStartTimes[navStartTimeIndex]; if (nearestMarker) { time -= nearestMarker.startTime - this.zeroTime(); } return this.formatValue(time, dividersData.precision); }; TimelineGrid.drawCanvasGrid(context, dividersData); if (this._rulerEnabled) { TimelineGrid.drawCanvasHeaders(context, dividersData, drawAdjustedTime, 3, HeaderHeight); } this._updateElementPosition(this._highlightElement, this._highlightedEntryIndex); this._updateElementPosition(this._selectedElement, this._selectedEntryIndex); this._updateMarkerHighlight(); } _initWebGL() { const gl = /** @type {?WebGLRenderingContext} */ (this._canvasGL.getContext('webgl')); if (!gl) { console.error('Failed to obtain WebGL context.'); this._useWebGL = false; // Fallback to use canvas. return; } const vertexShaderSource = ` attribute vec2 aVertexPosition; attribute float aVertexColor; uniform vec2 uScalingFactor; uniform vec2 uShiftVector; varying mediump vec2 vPalettePosition; void main() { vec2 shiftedPosition = aVertexPosition - uShiftVector; gl_Position = vec4(shiftedPosition * uScalingFactor + vec2(-1.0, 1.0), 0.0, 1.0); vPalettePosition = vec2(aVertexColor, 0.5); }`; const fragmentShaderSource = ` varying mediump vec2 vPalettePosition; uniform sampler2D uSampler; void main() { gl_FragColor = texture2D(uSampler, vPalettePosition); }`; /** * @param {!WebGLRenderingContext} gl * @param {number} type * @param {string} source * @return {?WebGLShader} */ function loadShader(gl, type, source) { const shader = gl.createShader(type); if (!shader) { return null; } gl.shaderSource(shader, source); gl.compileShader(shader); if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { return shader; } console.error('Shader compile error: ' + gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexShaderSource); const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource); const shaderProgram = gl.createProgram(); if (!shaderProgram || !vertexShader || !fragmentShader) { return; } gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); gl.linkProgram(shaderProgram); if (gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { this._shaderProgram = shaderProgram; gl.useProgram(shaderProgram); } else { this._shaderProgram = null; throw new Error('Unable to initialize the shader program: ' + gl.getProgramInfoLog(shaderProgram)); } this._vertexBuffer = gl.createBuffer(); this._colorBuffer = gl.createBuffer(); this._uScalingFactor = gl.getUniformLocation(shaderProgram, 'uScalingFactor'); this._uShiftVector = gl.getUniformLocation(shaderProgram, 'uShiftVector'); const uSampler = gl.getUniformLocation(shaderProgram, 'uSampler'); gl.uniform1i(uSampler, 0); this._aVertexPosition = gl.getAttribLocation(this._shaderProgram, 'aVertexPosition');