chrome-devtools-frontend
Version:
Chrome DevTools UI
1,340 lines (1,176 loc) • 84 kB
text/typescript
/**
* 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