UNPKG

chrome-devtools-frontend

Version:
1,340 lines (1,176 loc) 84 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 '../../../../core/common/common.js'; import * as i18n from '../../../../core/i18n/i18n.js'; import * as Platform from '../../../../core/platform/platform.js'; import type * as SDK from '../../../../core/sdk/sdk.js'; import type * as TimelineModel from '../../../../models/timeline_model/timeline_model.js'; import * as TraceEngine from '../../../../models/trace/trace.js'; import * as UI from '../../legacy.js'; import * as ThemeSupport from '../../theme_support/theme_support.js'; import {ChartViewport, type ChartViewportDelegate} from './ChartViewport.js'; import {TimelineGrid, type Calculator} from './TimelineGrid.js'; import flameChartStyles from './flameChart.css.legacy.js'; import {DEFAULT_FONT_SIZE, getFontFamilyForCanvas} from './Font.js'; 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('ui/legacy/components/perf_ui/FlameChart.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class FlameChartDelegate { windowChanged(_startTime: number, _endTime: number, _animate: boolean): void { } updateRangeSelection(_startTime: number, _endTime: number): void { } updateSelectedGroup(_flameChart: FlameChart, _group: Group|null): void { } } interface GroupExpansionState { [key: string]: boolean; } export class FlameChart extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.VBox>(UI.Widget.VBox) implements Calculator, ChartViewportDelegate { private readonly groupExpansionSetting?: Common.Settings.Setting<GroupExpansionState>; private groupExpansionState: GroupExpansionState; private readonly flameChartDelegate: FlameChartDelegate; private chartViewport: ChartViewport; private dataProvider: FlameChartDataProvider; private candyStripeCanvas: HTMLCanvasElement; private viewportElement: HTMLElement; private canvas: HTMLCanvasElement; private entryInfo: HTMLElement; private readonly markerHighlighElement: HTMLElement; readonly highlightElement: HTMLElement; private readonly selectedElement: HTMLElement; private rulerEnabled: boolean; private barHeight: number; private textBaseline: number; private textPadding: number; private readonly headerLeftPadding: number; private arrowSide: number; private readonly expansionArrowIndent: number; private readonly headerLabelXPadding: number; private readonly headerLabelYPadding: number; private highlightedMarkerIndex: number; private highlightedEntryIndex: number; private selectedEntryIndex: number; private rawTimelineDataLength: number; private readonly markerPositions: Map<number, { x: number, width: number, }>; private lastMouseOffsetX: number; private selectedGroup: number; private keyboardFocusedGroup: number; private offsetWidth!: number; private offsetHeight!: number; private dragStartX!: number; private dragStartY!: number; private lastMouseOffsetY!: number; private minimumBoundaryInternal!: number; private maxDragOffset!: number; private timelineLevels?: number[][]|null; private visibleLevelOffsets?: Uint32Array|null; private visibleLevels?: Uint16Array|null; private groupOffsets?: Uint32Array|null; private rawTimelineData?: FlameChartTimelineData|null; private forceDecorationCache?: Int8Array|null; private entryColorsCache?: string[]|null; private visibleLevelHeights?: Uint32Array; private totalTime?: number; #font: string; constructor( dataProvider: FlameChartDataProvider, flameChartDelegate: FlameChartDelegate, groupExpansionSetting?: Common.Settings.Setting<GroupExpansionState>) { super(true); this.#font = `${DEFAULT_FONT_SIZE} ${getFontFamilyForCanvas()}`; this.registerRequiredCSS(flameChartStyles); this.contentElement.classList.add('flame-chart-main-pane'); this.groupExpansionSetting = groupExpansionSetting; this.groupExpansionState = groupExpansionSetting && groupExpansionSetting.get() || {}; this.flameChartDelegate = flameChartDelegate; this.chartViewport = new ChartViewport(this); this.chartViewport.show(this.contentElement); this.dataProvider = dataProvider; this.candyStripeCanvas = document.createElement('canvas'); this.createCandyStripePattern(); this.viewportElement = this.chartViewport.viewportElement; this.canvas = (this.viewportElement.createChild('canvas', 'fill') as HTMLCanvasElement); 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.dispatchEventToListeners(Events.CanvasFocused); }, false); UI.UIUtils.installDragHandle( this.viewportElement, this.startDragging.bind(this), this.dragging.bind(this), this.endDragging.bind(this), null); this.rulerEnabled = true; this.barHeight = 17; this.textBaseline = 5; this.textPadding = 5; this.chartViewport.setWindowTimes( dataProvider.minimumBoundary(), dataProvider.minimumBoundary() + dataProvider.totalTime()); this.headerLeftPadding = 6; this.arrowSide = 8; this.expansionArrowIndent = this.headerLeftPadding + this.arrowSide / 2; this.headerLabelXPadding = 3; this.headerLabelYPadding = 2; this.highlightedMarkerIndex = -1; this.highlightedEntryIndex = -1; this.selectedEntryIndex = -1; this.rawTimelineDataLength = 0; 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; ThemeSupport.ThemeSupport.instance().addEventListener(ThemeSupport.ThemeChangeEvent.eventName, () => { this.scheduleUpdate(); }); } override willHide(): void { this.hideHighlight(); } setBarHeight(value: number): void { this.barHeight = value; } setTextBaseline(value: number): void { this.textBaseline = value; } setTextPadding(value: number): void { this.textPadding = value; } enableRuler(enable: boolean): void { this.rulerEnabled = enable; } alwaysShowVerticalScroll(): void { this.chartViewport.alwaysShowVerticalScroll(); } disableRangeSelection(): void { this.chartViewport.disableRangeSelection(); } highlightEntry(entryIndex: number): void { 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(): void { this.entryInfo.removeChildren(); this.highlightedEntryIndex = -1; this.updateElementPosition(this.highlightElement, this.highlightedEntryIndex); this.dispatchEventToListeners(Events.EntryHighlighted, -1); } private createCandyStripePattern(): void { // 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.8)'; for (let x = -size; x < size * 2; x += 3) { ctx.fillRect(x, -size, 1, size * 3); } } private resetCanvas(): void { 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`; } windowChanged(startTime: number, endTime: number, animate: boolean): void { this.flameChartDelegate.windowChanged(startTime, endTime, animate); } updateRangeSelection(startTime: number, endTime: number): void { this.flameChartDelegate.updateRangeSelection(startTime, endTime); } setSize(width: number, height: number): void { this.offsetWidth = width; this.offsetHeight = height; } private startDragging(event: MouseEvent): boolean { this.hideHighlight(); this.maxDragOffset = 0; this.dragStartX = event.pageX; this.dragStartY = event.pageY; return true; } private dragging(event: MouseEvent): void { const dx = event.pageX - this.dragStartX; const dy = event.pageY - this.dragStartY; this.maxDragOffset = Math.max(this.maxDragOffset, Math.sqrt(dx * dx + dy * dy)); } private endDragging(_event: MouseEvent): void { this.updateHighlight(); } private timelineData(): FlameChartTimelineData|null { 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; } private revealEntry(entryIndex: number): void { 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); } } setWindowTimes(startTime: number, endTime: number, animate?: boolean): void { this.chartViewport.setWindowTimes(startTime, endTime, animate); this.updateHighlight(); } private onMouseMove(event: Event): void { const mouseEvent = (event as MouseEvent); 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(); } private updateHighlight(): void { 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); } private onMouseOut(): void { this.lastMouseOffsetX = -1; this.lastMouseOffsetY = -1; this.hideHighlight(); } private updatePopover(entryIndex: number): void { if (entryIndex === this.highlightedEntryIndex) { this.updatePopoverOffset(); return; } this.entryInfo.removeChildren(); const popoverElement = this.dataProvider.prepareHighlightedEntryInfo(entryIndex); if (popoverElement) { this.entryInfo.appendChild(popoverElement); this.updatePopoverOffset(); } } private updatePopoverOffset(): void { 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'; } private onClick(event: Event): void { const mouseEvent = (event as MouseEvent); 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 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); } } private selectGroup(groupIndex: number): void { 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})); } else { this.selectedGroup = groupIndex; this.flameChartDelegate.updateSelectedGroup(this, groups[groupIndex]); this.resetCanvas(); this.draw(); UI.ARIAUtils.alert(i18nString(UIStrings.sSelected, {PH1: groupName})); } } private deselectAllGroups(): void { this.selectedGroup = -1; this.flameChartDelegate.updateSelectedGroup(this, null); this.resetCanvas(); this.draw(); } private deselectAllEntries(): void { this.selectedEntryIndex = -1; this.resetCanvas(); this.draw(); } private isGroupFocused(index: number): boolean { return index === this.selectedGroup || index === this.keyboardFocusedGroup; } private scrollGroupIntoView(index: number): void { 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); } private toggleGroupExpand(groupIndex: number): void { if (groupIndex < 0 || !this.isGroupCollapsible(groupIndex)) { return; } if (!this.rawTimelineData || !this.rawTimelineData.groups) { return; } this.expandGroup(groupIndex, !this.rawTimelineData.groups[groupIndex].expanded /* setExpanded */); } private expandGroup( groupIndex: number, setExpanded: boolean|undefined = true, propagatedExpand: boolean|undefined = false): void { 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); } } private onKeyDown(e: KeyboardEvent): void { 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); } } bindCanvasEvent(eventName: string, onEvent: (arg0: Event) => void): void { this.canvas.addEventListener(eventName, onEvent); } private handleKeyboardGroupNavigation(event: Event): void { const keyboardEvent = (event as KeyboardEvent); 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); } } private selectFirstEntryInCurrentGroup(): boolean { 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; } private selectPreviousGroup(): boolean { if (this.keyboardFocusedGroup <= 0) { return false; } const groupIndexToSelect = this.getGroupIndexToSelect(-1 /* offset */); this.selectGroup(groupIndexToSelect); return true; } private selectNextGroup(): boolean { 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; } private getGroupIndexToSelect(offset: number): number { 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; } private selectFirstChild(): void { 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); } } private handleSelectionNavigation(event: KeyboardEvent): boolean { if (this.selectedEntryIndex === -1) { return false; } const timelineData = this.timelineData(); if (!timelineData) { return false; } function timeComparator(time: number, entryIndex: number): number { if (!timelineData) { throw new Error('No timeline data'); } return time - timelineData.entryStartTimes[entryIndex]; } function entriesIntersect(entry1: number, entry2: number): boolean { 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 = (event as KeyboardEvent); 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 = Platform.ArrayUtilities.lowerBound(levelIndexes, this.selectedEntryIndex, (a, b) => a - b); 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; } private coordinatesToEntryIndex(x: number, y: number): number { 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 index as number; } } // Check regular entries. const entryStartTimes = timelineData.entryStartTimes; const entriesOnLevel: number[] = 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); function checkEntryHit(this: FlameChart, entryIndex: number|undefined): boolean { 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: number = entriesOnLevel[indexOnLevel]; if (checkEntryHit.call(this, entryIndex)) { return entryIndex; } entryIndex = entriesOnLevel[indexOnLevel + 1]; if (checkEntryHit.call(this, entryIndex)) { return entryIndex; } return -1; } private coordinatesToGroupIndex(x: number, y: number, headerOnly: boolean): number { 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 = (this.canvas.getContext('2d') as CanvasRenderingContext2D); context.save(); context.font = this.#font; const right = this.headerLeftPadding + this.labelWidthForGroup(context, groups[group]); context.restore(); if (x > right) { return -1; } return group; } private markerIndexBeforeTime(time: number): number { 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()); } private draw(): void { const timelineData = this.timelineData(); if (!timelineData) { return; } const canvasWidth = this.offsetWidth; const canvasHeight = this.offsetHeight; const context = (this.canvas.getContext('2d') as CanvasRenderingContext2D); 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, canvasWidth, canvasHeight); context.translate(0, -top); context.font = this.#font; const {markerIndices, colorBuckets, titleIndices} = this.getDrawableData(context, timelineData); context.save(); this.forEachGroupInViewport((offset, index, group, isFirst, groupHeight) => { if (this.isGroupFocused(index)) { context.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--selected-group-background', this.contentElement); context.fillRect(0, offset, canvasWidth, groupHeight - group.style.padding); } }); context.restore(); for (const [color, {indexes}] of colorBuckets) { this.#drawGenericEvents(context, timelineData, color, indexes); this.#drawDecorations(context, timelineData, indexes); } this.drawMarkers(context, timelineData, markerIndices); this.drawEventTitles(context, timelineData, titleIndices, canvasWidth); context.restore(); this.drawGroupHeaders(canvasWidth, canvasHeight); this.drawFlowEvents(context, canvasWidth, canvasHeight); this.drawMarkerLines(); const dividersData = TimelineGrid.calculateGridOffsets(this); const navStartTimes = Array.from(this.dataProvider.navStartTimes().values()); let navStartTimeIndex = 0; const drawAdjustedTime = (time: number): string => { 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(); } /** * Draws generic flame chart events, that is, the plain rectangles that fill several parts * in the timeline like the Main Thread flamechart and the timings track. * Drawn on a color by color basis to minimize the amount of times context.style is switched. */ #drawGenericEvents( context: CanvasRenderingContext2D, timelineData: FlameChartTimelineData, color: string, indexes: number[]): void { context.save(); context.beginPath(); for (let i = 0; i < indexes.length; ++i) { const entryIndex = indexes[i]; this.#drawEventRect(context, timelineData, entryIndex); } context.fillStyle = color; context.fill(); context.restore(); } /** * Draws decorations onto events. {@see FlameChartDecoration}. */ #drawDecorations(context: CanvasRenderingContext2D, timelineData: FlameChartTimelineData, indexes: number[]): void { const {entryTotalTimes, entryStartTimes, entryLevels} = timelineData; context.save(); for (let i = 0; i < indexes.length; ++i) { const entryIndex = indexes[i]; const decorationsForEvent = timelineData.entryDecorations.at(entryIndex); if (!decorationsForEvent || decorationsForEvent.length < 1) { continue; } if (decorationsForEvent.length > 1) { sortDecorationsForRenderingOrder(decorationsForEvent); } const entryStartTime = entryStartTimes[entryIndex]; const candyStripePattern = context.createPattern(this.candyStripeCanvas, 'repeat'); for (const decoration of decorationsForEvent) { const duration = entryTotalTimes[entryIndex]; if (decoration.type === 'CANDY') { const candyStripeStartTime = TraceEngine.Helpers.Timing.microSecondsToMilliseconds(decoration.startAtTime); if (duration < candyStripeStartTime) { // If the duration of the event is less than the start time to draw the candy stripes, then we have no stripes to draw. continue; } context.save(); context.beginPath(); // Draw a rectangle over the event, starting at the X value of the // event's start time + the startDuration of the candy striping. const barXStart = this.timeToPositionClipped(entryStartTime + candyStripeStartTime); const barXEnd = this.timeToPositionClipped(entryStartTime + duration); this.#drawEventRect(context, timelineData, entryIndex, { startX: barXStart, width: barXEnd - barXStart, }); if (candyStripePattern) { context.fillStyle = candyStripePattern; context.fill(); } context.restore(); } else if (decoration.type === 'WARNING_TRIANGLE') { const barX = this.timeToPositionClipped(entryStartTime); const barLevel = entryLevels[entryIndex]; const barHeight = this.#eventBarHeight(timelineData, entryIndex); const barY = this.levelToOffset(barLevel); const barWidth = this.#eventBarWidth(timelineData, entryIndex); const triangleSize = 8; context.save(); context.beginPath(); context.rect(barX, barY, barWidth, barHeight); context.clip(); context.beginPath(); context.fillStyle = 'red'; context.moveTo(barX + barWidth - triangleSize, barY); context.lineTo(barX + barWidth, barY); context.lineTo(barX + barWidth, barY + triangleSize); context.fill(); context.restore(); } } } context.restore(); } /** * Draws (but does not fill) a rectangle for a given event onto the provided * context. Because sometimes we need to draw a portion of the rect, it * optionally allows the start X and width of the rect to be overriden by * custom pixel values. It currently does not allow the start Y and height to * be changed because we have no need to do so, but this can be extended in * the future if required. **/ #drawEventRect( context: CanvasRenderingContext2D, timelineData: FlameChartTimelineData, entryIndex: number, overrides?: { startX?: number, width?: number, }): void { const {entryTotalTimes, entryStartTimes, entryLevels} = timelineData; const duration = entryTotalTimes[entryIndex]; if (isNaN(duration)) { return; } const entryStartTime = entryStartTimes[entryIndex]; const barX = overrides?.startX || this.timeToPositionClipped(entryStartTime); const barLevel = entryLevels[entryIndex]; const barHeight = this.#eventBarHeight(timelineData, entryIndex); const barY = this.levelToOffset(barLevel); const barWidth = overrides?.width || this.#eventBarWidth(timelineData, entryIndex); // We purposefully leave a 1px gap off the height so there is a small gap // visually between events vertically in the panel. // Similarly, we leave 0.5 pixels off the width so that there is a small // gap between events. Otherwise if a trace has a lot of events it looks // like one solid block and is not very easy to distinguish when events // start and end. context.rect(barX, barY, barWidth - 0.5, barHeight - 1); } #eventBarHeight(timelineData: FlameChartTimelineData, entryIndex: number): number { const {entryLevels} = timelineData; const barLevel = entryLevels[entryIndex]; const barHeight = this.levelHeight(barLevel); return barHeight; } #eventBarWidth(timelineData: FlameChartTimelineData, entryIndex: number): number { const {entryTotalTimes, entryStartTimes} = timelineData; const duration = entryTotalTimes[entryIndex]; const entryStartTime = entryStartTimes[entryIndex]; const barXStart = this.timeToPositionClipped(entryStartTime); const barXEnd = this.timeToPositionClipped(entryStartTime + duration); // Ensure that the width of the bar is at least one pixel. const barWidth = Math.max(barXEnd - barXStart, 1); return barWidth; } /** * Preprocess the data to be drawn to speed the rendering time. * Especifically: * - Groups events into color buckets. * - Discards non visible events. * - Gathers marker events (LCP, FCP, DCL, etc.). * - Gathers event titles that should be rendered. */ private getDrawableData(context: CanvasRenderingContext2D, timelineData: FlameChartTimelineData): {colorBuckets: Map<string, {indexes: number[]}>, titleIndices: number[], markerIndices: number[]} { // These are the event indexes of events that we are drawing onto the timeline that: // 1) have text within them // 2) are visually wide enough in pixels to make it worth rendering the text. const titleIndices: number[] = []; // These point to events that represent single points in the timeline, most // often an event such as DCL/LCP. const markerIndices: number[] = []; const {entryTotalTimes, entryStartTimes} = timelineData; const height = this.offsetHeight; const top = this.chartViewport.scrollOffset(); const visibleLevelOffsets = this.visibleLevelOffsets ? this.visibleLevelOffsets : new Uint32Array(); const textPadding = this.textPadding; // How wide in pixels / long in duration an event needs to be to make it // worthwhile rendering the text inside it. 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); // As we parse each event, we bucket them into groups based on the color we // will render them with. The key of this map will be a color, and all // events stored in the `indexes` array for that color will be painted as // such. This way, when rendering events, we can render them based on // color, and ensure the minimum amount of changes to context.fillStyle. const colorBuckets = new Map<string, {indexes: number[]}>(); 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. 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]; // Markers are single events in time (e.g. LCP): they do not have a duration. if (isNaN(duration)) { markerIndices.push(entryIndex); continue; } if (duration >= minTextWidthDuration || (this.forceDecorationCache && this.forceDecorationCache[entryIndex])) { // If the event is big enough visually to have its text rendered, // or if it's in the array of event indexes that we forcibly render (as defined by the data provider) // then we store its index. Later on, we'll loop through all // `titleIndices` to render the text for each event. titleIndices.push(entryIndex); } const entryStartTime = entryStartTimes[entryIndex]; const entryOffsetRight = entryStartTime + duration; if (entryOffsetRight <= this.chartViewport.windowLeftTime()) { break; } 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); } } } return {colorBuckets, titleIndices, markerIndices}; } private drawGroupHeaders(width: number, height: number): void { const context = (this.canvas.getContext('2d') as CanvasRenderingContext2D); const top = this.chartViewport.scrollOffset(); const ratio = window.devicePixelRatio; if (!this.rawTimelineData) { return; } const groups = this.rawTimelineData.groups || []; if (!groups.length) { return; } const groupOffsets = this.groupOffsets; if (groupOffsets === null || groupOffsets === undefined) { return; } const lastGroupOffset = groupOffsets[groupOffsets.length - 1]; context.save(); context.scale(ratio, ratio); context.translate(0, -top); context.font = this.#font; context.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-background'); this.forEachGroupInViewport((offset, index, group) => { const paddingHeight = group.style.padding; if (paddingHeight < 5) { return; } context.fillRect(0, offset - paddingHeight + 2, width, paddingHeight - 4); }); if (groups.length && lastGroupOffset < top + height) { context.fillRect(0, lastGroupOffset + 2, width, top + height - lastGroupOffset); } context.strokeStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-background-elevation-1'); context.beginPath(); this.forEachGroupInViewport((offset, index, group, isFirst) => { if (isFirst || group.style.padding < 4) { return; } hLine(offset - 2.5); }); hLine(lastGroupOffset + 1.5); context.stroke(); this.forEachGroupInViewport((offset, index, group) => { if (group.style.useFirstLineForOverview) { return; } if (!this.isGroupCollapsible(index) || group.expanded) { if (!group.style.shareHeaderLine && this.isGroupFocused(index)) { context.fillStyle = group.style.backgroundColor; context.fillRect(0, offset, width, group.style.height); } return; } let nextGroup = index + 1; while (nextGroup < groups.length && groups[nextGroup].style.nestingLevel > group.style.nestingLevel) { nextGroup++; } const endLevel = nextGroup < grou