UNPKG

chrome-devtools-frontend

Version:
1,244 lines (1,094 loc) • 178 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. */ /* eslint-disable rulesdir/no-imperative-dom-api */ 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 * as Trace from '../../../../models/trace/trace.js'; import * as VisualLogging from '../../../../ui/visual_logging/visual_logging.js'; import * as Buttons from '../../../components/buttons/buttons.js'; import * as UI from '../../legacy.js'; import * as ThemeSupport from '../../theme_support/theme_support.js'; import {drawExpansionArrow, drawIcon, horizontalLine} from './CanvasHelper.js'; import {ChartViewport, type ChartViewportDelegate} from './ChartViewport.js'; import flameChartStyles from './flameChart.css.js'; import {DEFAULT_FONT_SIZE, getFontFamilyForCanvas} from './Font.js'; import {type Calculator, TimelineGrid} from './TimelineGrid.js'; /** * Set as the `details` value on the fake context menu event we dispatch to * trigger a context menu on an event on a keyboard space key press. {@see onContextMenu} for more details and explanation. */ const KEYBOARD_FAKED_CONTEXT_MENU_DETAIL = -1; const UIStrings = { /** *@description Aria alert used to notify the user when an event has been selected because they tabbed into a group. *@example {Paint} PH1 *@example {Main thread} PH2 * */ eventSelectedFromGroup: 'Selected a {PH1} event within {PH2}. Press "enter" to focus this event.', /** *@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', /** *@description Text for an action that adds a label annotation to an entry in the Flame Chart */ labelEntry: 'Label entry', /** *@description Text for an action that adds link annotation between entries in the Flame Chart */ linkEntries: 'Link entries', /** *@description Text for an action that removes all annotations associated with an entry */ deleteAnnotations: 'Delete annotations', /** *@description Shown in the context menu when right clicking on a track header to enable the user to enter the track configuration mode. */ enterTrackConfigurationMode: 'Configure tracks', /** *@description Shown in the context menu when right clicking on a track header to allow the user to exit track configuration mode. */ exitTrackConfigurationMode: 'Finish configuring tracks', } as const; const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/perf_ui/FlameChart.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); /** * The expansion arrow is drawn from the center, so the indent is in fact the center of the arrow. * See `drawExpansionArrow` function to understand how we draw the arrow. * |headerLeftPadding|Arrow| * |expansionArrowIndent| * * When we are in edit mode, we render 3 icons to the left of the track's title. * When we are in normal mode, there are no icons to the left of the track's title. **/ // Placed to the left of the track header. const HEADER_LEFT_PADDING = 6; export const ARROW_SIDE = 8; const EXPANSION_ARROW_INDENT = HEADER_LEFT_PADDING + ARROW_SIDE / 2; const HEADER_LABEL_X_PADDING = 3; const HEADER_LABEL_Y_PADDING = 2; // The width of each of the edit mode icons. export const EDIT_ICON_WIDTH = 16; // This gap might seem quite small - but the icons themselves have some // whitespace either side, so we don't need a huge gap. const GAP_BETWEEN_EDIT_ICONS = 3; // The UP icon is first, and is rendered in from the left just as the track text. const UP_ICON_LEFT = HEADER_LEFT_PADDING; // The DOWN icon is after the UP icon, hence we take the up icon's position, // add its width and then the gap between them. const DOWN_ICON_LEFT = UP_ICON_LEFT + EDIT_ICON_WIDTH + GAP_BETWEEN_EDIT_ICONS; // The HIDE icon is after the DOWN icon, hence we take the up icon's position, // add its width and then the gap between them. const HIDE_ICON_LEFT = DOWN_ICON_LEFT + EDIT_ICON_WIDTH + GAP_BETWEEN_EDIT_ICONS; // Represents the total width taken by the 3 icons (up, down, hide/show, and // the gap between them.) // We calculate this by taking the space to the left of the hide icon (which // encompasses UP/DOWN icons), adding on the width of the HIDE icon, and then a // bit of extra padding. const EDIT_MODE_TOTAL_ICON_WIDTH = HIDE_ICON_LEFT + EDIT_ICON_WIDTH + GAP_BETWEEN_EDIT_ICONS; // These are copied from front_end/images/*.svg, because we need to draw them with canvas. // arrow-up.svg const moveUpIconPath = 'M9.25 17V5.875L7.062 8.062L6 7L10 3L14 7L12.938 8.062L10.75 5.875V17H9.25Z'; // arrow-down.svg const moveDownIconPath = 'M9.25 3V14.125L7.062 11.938L6 13L10 17L14 13L12.938 11.938L10.75 14.125V3H9.25Z'; // eye-crossed.svg const hideIconPath = 'M13.2708 11.1459L11.9792 9.85419C12.0347 9.32641 11.875 8.87155 11.5 8.4896C11.125 8.10766 10.6736 7.94446 10.1458 8.00002L8.85417 6.70835C9.03472 6.63891 9.22222 6.58683 9.41667 6.5521C9.61111 6.51738 9.80556 6.50002 10 6.50002C10.9722 6.50002 11.7986 6.8403 12.4792 7.52085C13.1597 8.20141 13.5 9.0278 13.5 10C13.5 10.1945 13.4826 10.3889 13.4479 10.5834C13.4132 10.7778 13.3542 10.9653 13.2708 11.1459ZM16.0417 13.9167L14.9583 12.8334C15.4583 12.4445 15.9132 12.0174 16.3229 11.5521C16.7326 11.0868 17.0764 10.5695 17.3542 10C16.6736 8.59724 15.6701 7.49655 14.3438 6.69794C13.0174 5.89933 11.5694 5.50002 10 5.50002C9.63889 5.50002 9.28472 5.52085 8.9375 5.56252C8.59028 5.60419 8.25 5.67363 7.91667 5.77085L6.70833 4.56252C7.23611 4.35419 7.77431 4.20835 8.32292 4.12502C8.87153 4.04169 9.43056 4.00002 10 4.00002C11.9861 4.00002 13.8021 4.53821 15.4479 5.6146C17.0938 6.69099 18.2778 8.1528 19 10C18.6944 10.7917 18.2882 11.5104 17.7813 12.1563C17.2743 12.8021 16.6944 13.3889 16.0417 13.9167ZM16 18.125L13.2917 15.4167C12.7639 15.6111 12.2257 15.757 11.6771 15.8542C11.1285 15.9514 10.5694 16 10 16C8.01389 16 6.19792 15.4618 4.55208 14.3854C2.90625 13.309 1.72222 11.8472 1 10C1.30556 9.20835 1.70833 8.48613 2.20833 7.83335C2.70833 7.18058 3.29167 6.5903 3.95833 6.06252L1.875 3.97919L2.9375 2.91669L17.0625 17.0625L16 18.125ZM5.02083 7.14585C4.53472 7.53474 4.08333 7.96183 3.66667 8.4271C3.25 8.89238 2.90972 9.41669 2.64583 10C3.32639 11.4028 4.32986 12.5035 5.65625 13.3021C6.98264 14.1007 8.43056 14.5 10 14.5C10.3611 14.5 10.7153 14.4757 11.0625 14.4271C11.4097 14.3785 11.7569 14.3125 12.1042 14.2292L11.1667 13.2917C10.9722 13.3611 10.7778 13.4132 10.5833 13.4479C10.3889 13.4827 10.1944 13.5 10 13.5C9.02778 13.5 8.20139 13.1597 7.52083 12.4792C6.84028 11.7986 6.5 10.9722 6.5 10C6.5 9.80558 6.52431 9.61113 6.57292 9.41669C6.62153 9.22224 6.66667 9.0278 6.70833 8.83335L5.02083 7.14585Z'; // eye.svg const showIconPath = 'M10 13.5C10.972 13.5 11.7983 13.1597 12.479 12.479C13.1597 11.7983 13.5 10.972 13.5 10C13.5 9.028 13.1597 8.20167 12.479 7.521C11.7983 6.84033 10.972 6.5 10 6.5C9.028 6.5 8.20167 6.84033 7.521 7.521C6.84033 8.20167 6.5 9.028 6.5 10C6.5 10.972 6.84033 11.7983 7.521 12.479C8.20167 13.1597 9.028 13.5 10 13.5ZM10 12C9.44467 12 8.97233 11.8057 8.583 11.417C8.19433 11.0277 8 10.5553 8 10C8 9.44467 8.19433 8.97233 8.583 8.583C8.97233 8.19433 9.44467 8 10 8C10.5553 8 11.0277 8.19433 11.417 8.583C11.8057 8.97233 12 9.44467 12 10C12 10.5553 11.8057 11.0277 11.417 11.417C11.0277 11.8057 10.5553 12 10 12ZM10 16C8.014 16 6.20833 15.455 4.583 14.365C2.95833 13.2743 1.764 11.8193 1 10C1.764 8.18067 2.95833 6.72567 4.583 5.635C6.20833 4.545 8.014 4 10 4C11.986 4 13.7917 4.545 15.417 5.635C17.0417 6.72567 18.236 8.18067 19 10C18.236 11.8193 17.0417 13.2743 15.417 14.365C13.7917 15.455 11.986 16 10 16ZM10 14.5C11.5553 14.5 12.9927 14.0973 14.312 13.292C15.632 12.486 16.646 11.3887 17.354 10C16.646 8.61133 15.632 7.514 14.312 6.708C12.9927 5.90267 11.5553 5.5 10 5.5C8.44467 5.5 7.00733 5.90267 5.688 6.708C4.368 7.514 3.354 8.61133 2.646 10C3.354 11.3887 4.368 12.486 5.688 13.292C7.00733 14.0973 8.44467 14.5 10 14.5Z'; // export for test. export const enum HoverType { TRACK_CONFIG_UP_BUTTON = 'TRACK_CONFIG_UP_BUTTON', TRACK_CONFIG_DOWN_BUTTON = 'TRACK_CONFIG_DOWN_BUTTON', TRACK_CONFIG_HIDE_BUTTON = 'TRACK_CONFIG_HIDE_BUTTON', TRACK_CONFIG_SHOW_BUTTON = 'TRACK_CONFIG_SHOW_BUTTON', INSIDE_TRACK_HEADER = 'INSIDE_TRACK_HEADER', INSIDE_TRACK = 'INSIDE_TRACK', OUTSIDE_TRACKS = 'OUTSIDE_TRACKS', ERROR = 'ERROR', } export interface FlameChartDelegate { windowChanged(_startTime: number, _endTime: number, _animate: boolean): void; updateRangeSelection(_startTime: number, _endTime: number): void; updateSelectedGroup(_flameChart: FlameChart, _group: Group|null): void; /** * Returns the element that the FlameChart has been rendered into. Used to * provide element references for attaching to Visual Element logs. */ containingElement?: () => HTMLElement; } interface PopoverState { // Index of the last entry the popover was shown over. entryIndex: number|null; // Index of the last group the popover was shown over. groupIndex: number; hiddenEntriesPopover: boolean; } interface GroupTreeNode { index: number; nestingLevel: number; startLevel: number; endLevel: number; // The order in children is the visible order of them. children: GroupTreeNode[]; } export interface OptionalFlameChartConfig { /** * The FlameChart will highlight the entry that is selected by default. In * some cases (Performance Panel) we manage this ourselves with the Overlays * system, so we disable the built in one. */ selectedElementOutline?: boolean; /** * The element to use when populating and positioning the mouse tooltip. */ tooltipElement?: HTMLElement; /** * Used to disable the cursor element in ChartViewport and instead use the new overlays system. */ useOverlaysForCursorRuler?: boolean; } export const enum FilterAction { MERGE_FUNCTION = 'MERGE_FUNCTION', COLLAPSE_FUNCTION = 'COLLAPSE_FUNCTION', COLLAPSE_REPEATING_DESCENDANTS = 'COLLAPSE_REPEATING_DESCENDANTS', RESET_CHILDREN = 'RESET_CHILDREN', UNDO_ALL_ACTIONS = 'UNDO_ALL_ACTIONS', } export interface UserFilterAction { type: FilterAction; entry: Trace.Types.Events.Event; } // Object used to indicate to the Context Menu if an action is possible on the selected entry. export interface PossibleFilterActions { [FilterAction.MERGE_FUNCTION]: boolean; [FilterAction.COLLAPSE_FUNCTION]: boolean; [FilterAction.COLLAPSE_REPEATING_DESCENDANTS]: boolean; [FilterAction.RESET_CHILDREN]: boolean; [FilterAction.UNDO_ALL_ACTIONS]: boolean; } export interface PositionOverride { x: number; width: number; /** The z index of this entry. Use -1 if placing it underneath other entries. A z of 0 is assumed, otherwise, much like CSS's z-index */ z?: number; } export type DrawOverride = (context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, timeToPosition: (time: number) => number, transformColor: (color: string) => string) => PositionOverride; export class FlameChart extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.VBox>(UI.Widget.VBox) implements Calculator, ChartViewportDelegate { private readonly flameChartDelegate: FlameChartDelegate; private chartViewport: ChartViewport; private dataProvider: FlameChartDataProvider; private candyStripePattern: CanvasPattern|null; private candyStripePatternGray: CanvasPattern|null; private contextMenu?: UI.ContextMenu.ContextMenu; private viewportElement: HTMLElement; private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; private popoverElement: HTMLElement; private readonly markerHighlighElement: HTMLElement; readonly highlightElement: HTMLElement; readonly revealDescendantsArrowHighlightElement: HTMLElement; private readonly selectedElement: HTMLElement|null = null; private rulerEnabled: boolean; private barHeight: number; // Additional space around an entry that is added for operations with entry. // It allows for less pecision while selecting/hovering over an entry. private hitMarginPx: number; private textBaseline: number; private textPadding: number; private highlightedMarkerIndex: number; /** * The index of the entry that's hovered (typically), or focused because of searchResult or other reasons.focused via searchResults, or focused by other means. * Updated as the cursor moves. Meanwhile `selectedEntryIndex` is the entry that's been clicked. **/ private highlightedEntryIndex: number; /** * Represents the index of the entry that is selected. For an entry to be * selected, it has to be clicked by the user (generally). **/ private selectedEntryIndex: number; private rawTimelineDataLength: number; private readonly markerPositions: Map<number, PositionOverride>; private readonly customDrawnPositions: Map<number, PositionOverride>; private lastMouseOffsetX: number; private selectedGroupIndex: 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?: boolean[]|null; private visibleLevelHeights?: Uint32Array; private groupOffsets?: Uint32Array|null; private rawTimelineData?: FlameChartTimelineData|null; private forceDecorationCache?: boolean[]|null; private entryColorsCache?: string[]|null; private colorDimmingCache = new Map<string, string>(); private totalTime?: number; private lastPopoverState: PopoverState; private dimIndices?: Uint8Array|null; /** When true, all undimmed entries are outlined. When an array, only those indices are outlined (if not dimmed). */ private dimShouldOutlineUndimmedEntries: boolean|Uint8Array = false; #tooltipPopoverYAdjustment = 0; #font: string; #groupTreeRoot?: GroupTreeNode|null; #searchResultEntryIndex: number|null = null; #inTrackConfigEditMode = false; #linkSelectionAnnotationIsInProgress = false; // Stored because we cache this value to save extra lookups and layoffs. #canvasBoundingClientRect: DOMRect|null = null; #selectedElementOutlineEnabled = true; #indexToDrawOverride = new Map<number, DrawOverride>(); #persistedGroupConfig: PersistedGroupConfig[]|null = null; constructor( dataProvider: FlameChartDataProvider, flameChartDelegate: FlameChartDelegate, optionalConfig: OptionalFlameChartConfig = {}) { super(true); this.#font = `${DEFAULT_FONT_SIZE} ${getFontFamilyForCanvas()}`; this.registerRequiredCSS(flameChartStyles); this.registerRequiredCSS(UI.inspectorCommonStyles); this.contentElement.classList.add('flame-chart-main-pane'); if (typeof optionalConfig.selectedElementOutline === 'boolean') { this.#selectedElementOutlineEnabled = optionalConfig.selectedElementOutline; } this.flameChartDelegate = flameChartDelegate; // The ChartViewport has its own built-in ruler for when the user holds // shift and moves the mouse. We want to disable that if we are within the // performance panel where we use overlays, but enable it otherwise. let enableCursorElement = true; if (typeof optionalConfig.useOverlaysForCursorRuler === 'boolean') { enableCursorElement = !optionalConfig.useOverlaysForCursorRuler; } this.chartViewport = new ChartViewport(this, { enableCursorElement, }); this.chartViewport.show(this.contentElement); this.dataProvider = dataProvider; this.viewportElement = this.chartViewport.viewportElement; this.canvas = this.viewportElement.createChild('canvas', 'fill'); this.context = this.canvas.getContext('2d') as CanvasRenderingContext2D; this.candyStripePattern = this.candyStripePatternGray = null; this.canvas.tabIndex = 0; UI.ARIAUtils.setLabel(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('dblclick', this.#onDblClick.bind(this), false); this.canvas.addEventListener('keydown', this.onKeyDown.bind(this), false); this.canvas.addEventListener('contextmenu', this.onContextMenu.bind(this), false); this.popoverElement = optionalConfig.tooltipElement || 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.revealDescendantsArrowHighlightElement = this.viewportElement.createChild('div', 'reveal-descendants-arrow-highlight-element'); if (this.#selectedElementOutlineEnabled) { this.selectedElement = this.viewportElement.createChild('div', 'flame-chart-selected-element'); } this.canvas.addEventListener('focus', () => { this.dispatchEventToListeners(Events.CANVAS_FOCUSED); }, 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.hitMarginPx = 3; this.textBaseline = 5; this.textPadding = 5; this.chartViewport.setWindowTimes( dataProvider.minimumBoundary(), dataProvider.minimumBoundary() + dataProvider.totalTime()); this.highlightedMarkerIndex = -1; this.highlightedEntryIndex = -1; this.selectedEntryIndex = -1; this.#searchResultEntryIndex = null; this.rawTimelineDataLength = 0; this.markerPositions = new Map(); this.customDrawnPositions = new Map(); this.lastMouseOffsetX = 0; this.selectedGroupIndex = -1; this.lastPopoverState = { entryIndex: -1, groupIndex: -1, hiddenEntriesPopover: false, }; // 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(); } canvasBoundingClientRect(): DOMRect|null { // If we have a rect already, and it has width & height, use it by default. // The reason we check the dimensions is because otherwise if this method was // called before the FlameChart was fully rendered it might have been // calculated with a width or height of 0, and that is clearly incorrect. if (this.#canvasBoundingClientRect && this.#canvasBoundingClientRect.width > 0 && this.#canvasBoundingClientRect.height > 0) { return this.#canvasBoundingClientRect; } this.#canvasBoundingClientRect = this.canvas.getBoundingClientRect(); return this.#canvasBoundingClientRect; } /** * In some cases we need to manually adjust the positioning of the tooltip * vertically to account for the fact that it might be rendered not relative * to just this flame chart. This is true of the main flame chart in the * Performance Panel where the element is rendered in a higher-stack container * and we need to manually adjust its Y position to correctly put the tooltip * in the right place. */ setTooltipYPixelAdjustment(y: number): void { if (y === this.#tooltipPopoverYAdjustment) { return; } this.#tooltipPopoverYAdjustment = y; // Reposition the popover if it has any children (otherwise it is not visible) if (this.popoverElement.children.length) { this.updatePopoverOffset(); } } getBarHeight(): number { return this.barHeight; } 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(); } #shouldDimEvent(entryIndex: number): boolean { if (this.dimIndices) { return this.dimIndices[entryIndex] !== 0; } return false; } /** * Returns true only if dimming is active, but not for this specific entry. * Also checks `dimShouldOutlineUndimmedEntries`. */ #shouldOutlineEvent(entryIndex: number): boolean { if (!this.isDimming() || this.#shouldDimEvent(entryIndex)) { return false; } if (ArrayBuffer.isView(this.dimShouldOutlineUndimmedEntries)) { return this.dimShouldOutlineUndimmedEntries[entryIndex] !== 0; } return this.dimShouldOutlineUndimmedEntries; } /** * Returns a contiguous boolean array for quick lookup during drawing. */ #createTypedIndexArray(indices: number[], inclusive: boolean): Uint8Array { const typedIndices = new Uint8Array(this.rawTimelineDataLength); if (inclusive) { for (const index of indices) { typedIndices[index] = 1; } } else { typedIndices.fill(1); for (const index of indices) { typedIndices[index] = 0; } } return typedIndices; } enableDimming(entryIndices: number[], inclusive: boolean, outline: boolean|number[]): void { this.dimIndices = this.#createTypedIndexArray(entryIndices, inclusive); this.dimShouldOutlineUndimmedEntries = Array.isArray(outline) ? this.#createTypedIndexArray(outline, true) : outline; this.draw(); } disableDimming(): void { this.dimIndices = null; this.dimShouldOutlineUndimmedEntries = false; this.draw(); } isDimming(): boolean { return Boolean(this.dimIndices); } #transformColor(entryIndex: number, color: string): string { if (this.#shouldDimEvent(entryIndex)) { let dimmed = this.colorDimmingCache.get(color); if (dimmed) { return dimmed; } const parsedColor = Common.Color.parse(color); dimmed = parsedColor ? parsedColor.asLegacyColor().grayscale().asString() : 'lightgrey'; this.colorDimmingCache.set(color, dimmed); return dimmed; } return color; } getColorForEntry(entryIndex: number): string { if (!this.entryColorsCache) { return ''; } return this.#transformColor(entryIndex, this.entryColorsCache[entryIndex]); } 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.ENTRY_HOVERED, entryIndex); } hideHighlight(): void { if (this.#searchResultEntryIndex === null) { this.popoverElement.removeChildren(); this.lastPopoverState = { entryIndex: -1, groupIndex: -1, hiddenEntriesPopover: false, }; } if (this.highlightedEntryIndex === -1) { return; } this.highlightedEntryIndex = -1; this.updateElementPosition(this.highlightElement, this.highlightedEntryIndex); this.dispatchEventToListeners(Events.ENTRY_HOVERED, -1); } private createCandyStripePattern(color: string): CanvasPattern { // Set the candy stripe pattern to 17px so it repeats well. const size = 17; const candyStripeCanvas = document.createElement('canvas'); candyStripeCanvas.width = size; candyStripeCanvas.height = size; const ctx = candyStripeCanvas.getContext('2d', {willReadFrequently: true}) as CanvasRenderingContext2D; // 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 = color; for (let x = -size; x < size * 2; x += 3) { ctx.fillRect(x, -size, 1, size * 3); } // Because we're using a canvas, we know createPattern won't return null // https://html.spec.whatwg.org/multipage/canvas.html#dom-context-2d-createpattern-dev return ctx.createPattern(candyStripeCanvas, 'repeat') as CanvasPattern; } 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(); } timelineData(rebuild?: boolean): FlameChartTimelineData|null { if (!this.dataProvider) { return null; } const timelineData = this.dataProvider.timelineData(rebuild); if (timelineData !== this.rawTimelineData || (timelineData && timelineData.entryStartTimes.length !== this.rawTimelineDataLength)) { this.processTimelineData(timelineData); } return this.rawTimelineData || null; } revealEntryVertically(entryIndex: number): void { const timelineData = this.timelineData(); if (!timelineData) { return; } const level = timelineData.entryLevels[entryIndex]; this.chartViewport.setScrollOffset(this.levelToOffset(level), this.levelHeight(level), true); } 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]; let entryTotalTime = timelineData.entryTotalTimes[entryIndex]; // Marker entries have NaN durations; for the sake of the reveal logic // let's pretend they have a 1ms duration so we can calculate a reasonable // time window to reveal if (Number.isNaN(entryTotalTime)) { entryTotalTime = 1; } 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(); } /** * Handle the mouse move event. The handle priority will be: * 1. Track configuration icons -> show tooltip for the icons * 2. Inside a track header -> mouse style will be a "pointer", indicating track can be focused * 3. Inside a track -> update the highlight of hovered event */ private onMouseMove(mouseEvent: MouseEvent): void { this.#searchResultEntryIndex = null; this.lastMouseOffsetX = mouseEvent.offsetX; this.lastMouseOffsetY = mouseEvent.offsetY; if (!this.enabled()) { return; } if (this.chartViewport.isDragging()) { return; } const timeMilliSeconds = Trace.Types.Timing.Milli(this.chartViewport.pixelToTime(mouseEvent.offsetX)); this.dispatchEventToListeners(Events.MOUSE_MOVE, { mouseEvent, timeInMicroSeconds: Trace.Helpers.Timing.milliToMicro(timeMilliSeconds), }); // Check if the mouse is hovering any group's header area const {groupIndex, hoverType} = this.coordinatesToGroupIndexAndHoverType(mouseEvent.offsetX, mouseEvent.offsetY); switch (hoverType) { case HoverType.TRACK_CONFIG_UP_BUTTON: case HoverType.TRACK_CONFIG_DOWN_BUTTON: case HoverType.TRACK_CONFIG_HIDE_BUTTON: case HoverType.TRACK_CONFIG_SHOW_BUTTON: { this.hideHighlight(); this.viewportElement.style.cursor = 'pointer'; const iconTooltipElement = this.#prepareIconInfo(groupIndex, hoverType); if (iconTooltipElement) { this.popoverElement.appendChild(iconTooltipElement); this.updatePopoverOffset(); } return; } case HoverType.INSIDE_TRACK_HEADER: this.updateHighlight(); this.viewportElement.style.cursor = 'pointer'; return; case HoverType.INSIDE_TRACK: case HoverType.OUTSIDE_TRACKS: this.updateHighlight(); return; case HoverType.ERROR: return; default: Platform.assertNever(hoverType, `Invalid hovering type: ${hoverType}`); } } #prepareIconInfo(groupIndex: number, iconType: HoverType): Element|null { const group = this.rawTimelineData?.groups[groupIndex]; if (!group) { return null; } // Only show first 20 characters to make the tooltip not too long. const maxTitleChars = 20; const displayName = Platform.StringUtilities.trimMiddle(group.name, maxTitleChars); let iconTooltip = ''; switch (iconType) { case HoverType.TRACK_CONFIG_UP_BUTTON: iconTooltip = `Move ${displayName} track up`; break; case HoverType.TRACK_CONFIG_DOWN_BUTTON: iconTooltip = `Move ${displayName} track down`; break; case HoverType.TRACK_CONFIG_HIDE_BUTTON: if (this.groupIsLastVisibleTopLevel(groupIndex)) { iconTooltip = 'Can not hide the last top level track'; } else { iconTooltip = `Hide ${displayName} track`; } break; case HoverType.TRACK_CONFIG_SHOW_BUTTON: iconTooltip = `Show ${displayName} track`; break; default: return null; } const element = document.createElement('div'); element.createChild('span', 'popoverinfo-title').textContent = iconTooltip; return element; } private updateHighlight(): void { const entryIndex = this.coordinatesToEntryIndex(this.lastMouseOffsetX, this.lastMouseOffsetY); // Each time the entry highlight is updated, we need to check if the mouse is hovering over a // button that indicates hidden child elements and if so, update the button highlight. this.updateHiddenChildrenArrowHighlighPosition(entryIndex); // No entry is hovered. if (entryIndex === -1) { this.hideHighlight(); const {groupIndex, hoverType} = this.coordinatesToGroupIndexAndHoverType(this.lastMouseOffsetX, this.lastMouseOffsetY); if (hoverType === HoverType.INSIDE_TRACK_HEADER) { this.#updatePopoverForGroup(groupIndex); } if (groupIndex >= 0 && this.rawTimelineData?.groups?.[groupIndex].selectable) { // This means the mouse is in a selectable group's area, and not hovering any entry. this.viewportElement.style.cursor = 'pointer'; } else { // This means the mouse is not hovering any selectable track, and not hovering any entry. this.viewportElement.style.cursor = 'default'; } return; } // Some entry is hovered. if (this.chartViewport.isDragging()) { return; } this.#updatePopoverForEntry(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(); } showPopoverForSearchResult(selectedSearchResult: number|null): void { this.#searchResultEntryIndex = selectedSearchResult; this.#updatePopoverForEntry(selectedSearchResult); } #updatePopoverForEntry(entryIndex: number|null): void { // Just update position if cursor is hovering the same entry. const isMouseOverRevealChildrenArrow = entryIndex !== null && this.isMouseOverRevealChildrenArrow(this.lastMouseOffsetX, entryIndex); if (entryIndex === this.lastPopoverState.entryIndex && isMouseOverRevealChildrenArrow === this.lastPopoverState.hiddenEntriesPopover) { return this.updatePopoverOffset(); } const data = this.timelineData(); if (!data) { return; } const group = data.groups.at(this.selectedGroupIndex); // If the mouse is hovering over the hidden descendants arrow, get an element that shows how many children are hidden, otherwise an element with the event name and length const popoverElement = (isMouseOverRevealChildrenArrow && group) ? this.dataProvider.preparePopoverForCollapsedArrow?.(entryIndex) : entryIndex !== null && this.dataProvider.preparePopoverElement(entryIndex); if (popoverElement) { this.updatePopoverContents(popoverElement); } this.lastPopoverState = { entryIndex, groupIndex: -1, hiddenEntriesPopover: isMouseOverRevealChildrenArrow, }; } updatePopoverContents(popoverElement: Element): void { this.popoverElement.removeChildren(); this.popoverElement.appendChild(popoverElement); // Must update the offset AFTER the new content has been added. this.updatePopoverOffset(); this.lastPopoverState.entryIndex = -1; } updateMouseOffset(mouseX: number, mouseY: number): void { this.lastMouseOffsetX = mouseX; this.lastMouseOffsetY = mouseY; } #updatePopoverForGroup(groupIndex: number): void { // Just update position if cursor is hovering the group name. if (groupIndex === this.lastPopoverState.groupIndex) { return this.updatePopoverOffset(); } this.popoverElement.removeChildren(); const data = this.timelineData(); if (!data) { return; } const group = data.groups.at(groupIndex); if (group?.description) { this.popoverElement.innerText = (group?.description); this.updatePopoverOffset(); } this.lastPopoverState = { groupIndex, entryIndex: -1, hiddenEntriesPopover: false, }; } private updatePopoverOffset(): void { let mouseX = this.lastMouseOffsetX; let mouseY = this.lastMouseOffsetY; // If the popover is being updated from a search, we calculate the coordinates manually if (this.#searchResultEntryIndex !== null) { const coordinate = this.entryIndexToCoordinates(this.selectedEntryIndex); const {x: canvasViewportOffsetX, y: canvasViewportOffsetY} = this.canvas.getBoundingClientRect(); mouseX = coordinate?.x ? coordinate.x - canvasViewportOffsetX : mouseX; mouseY = coordinate?.y ? coordinate.y - canvasViewportOffsetY : mouseY; } // The parent dimensions are the maximum the popover can use. const parentWidth = this.popoverElement.parentElement ? this.popoverElement.parentElement.clientWidth : 0; const parentHeight = this.popoverElement.parentElement ? this.popoverElement.parentElement.clientHeight : 0; const infoWidth = this.popoverElement.clientWidth; const infoHeight = this.popoverElement.clientHeight; // How much offset to use (when placing popover relative to mouseX/mouseY) const offsetX = 10; // Incorporate any network flamechart height into dynamic positioning const offsetY = 6 + this.#tooltipPopoverYAdjustment; let x; let y; /** * Fancy positioning algorithm. It optimizes for consistent positioning, not obstructing any of the popover, and not positioning atop the mouse cursor. * * Take the mouse cursor position (mouseX/mouseY) and split up the area into four quadrants * 0: bottom-right. 1: top-right. 2: bottom-left. 3: top-left. * * We attempt this in two passes, first is for keeping the whole popover visible, the second is slightly relaxed. * If we hit the second pass, its because the tooltip size is close to the size of the available (parent*) space. * In each pass, we loop through the quadrants * If the tooltip can fit (after some adjustments) within a quadrant, we `break` and that x,y is used. */ for (let pass = 0; pass < 2; ++pass) { for (let quadrant = 0; quadrant < 4; ++quadrant) { // The bitwise AND operator is used to generate the 4 unique combinations of two booleans. (true+false, true+true, etc) const dx = quadrant & 2 ? -offsetX - infoWidth : offsetX; const dy = quadrant & 1 ? -offsetY - infoHeight : offsetY; // mouseX+dx is ideal, but clamp against the available space (It will be adapted to fit) x = Platform.NumberUtilities.clamp(mouseX + dx, 0, parentWidth - infoWidth); y = Platform.NumberUtilities.clamp(mouseY + dy, 0, parentHeight - infoHeight); const popoverFits = pass === 0 ? // Will the whole popover be visible? (x >= mouseX || mouseX >= x + infoWidth) && (y >= mouseY || mouseY >= y + infoHeight) : // Will the popover fit well in 1 dimension? (Though we typically see it fit in both, here. Shrug.) x >= mouseX || mouseX >= x + infoWidth || y >= mouseY || mouseY >= y + infoHeight; if (popoverFits) { break; } } } this.popoverElement.style.left = x + 'px'; this.popoverElement.style.top = y + 'px'; } /** * Handle double mouse click event in flame chart. */ #onDblClick(mouseEvent: MouseEvent): void { this.focus(); const {groupIndex} = this.coordinatesToGroupIndexAndHoverType(mouseEvent.offsetX, mouseEvent.offsetY); /** * When a hovered entry on any track is double clicked, create a label for it. * * Checking the existence of `highlightedEntryIndex` is enough to make sure that the double * click happened on the entry since an entry is only highlighted if the mouse is hovering it. */ if (this.highlightedEntryIndex !== -1) { this.#selectGroup(groupIndex); this.dispatchEventToListeners( Events.ENTRY_LABEL_ANNOTATION_ADDED, {entryIndex: this.highlightedEntryIndex, withLinkCreationButton: true}); // Log the double click on the TimelineFlameChartView for VE logs. const flameChartView = this.flameChartDelegate.containingElement?.(); if (flameChartView) { VisualLogging.logClick(flameChartView, mouseEvent, {doubleClick: true}); } } } /** * Handle mouse click event in flame chart * * And the handle priority will be: * 1. Track configuration icons -> Config a track * 1.1 if it's edit mode ignore others. * 2. Inside a track header -> Select and Expand/Collapse a track * 3. Inside a track -> Select a track * 3.1 shift + click -> Select the time range of clicked event * 3.2 click -> update highlight (handle in other functions) */ private onClick(mouseEvent: MouseEvent): void { 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; } // If any button is clicked, we should handle the action only and ignore others. const {groupIndex, hoverType} = this.coordinatesToGroupIndexAndHoverType(mouseEvent.offsetX, mouseEvent.offsetY); // There could be a special case, when there is no group and all entries are appended directly, for example the // Memory panel. // In this case, the |groupIndex| will be -1, and |groups| should be empty. // All the functions here can handle the -1 groupIndex properly, so we don't need to add extra check here. switch (hoverType) { case HoverType.TRACK_CONFIG_UP_BUTTON: this.moveGroupUp(groupIndex); return; case HoverType.TRACK_CONFIG_DOWN_BUTTON: this.moveGroupDown(groupIndex); return; case HoverType.TRACK_CONFIG_HIDE_BUTTON: if (this.groupIsLastVisibleTopLevel(groupIndex)) { // If this is the last visible top-level group, we will not allow you hiding the track. return; } this.hideGroup(groupIndex); return; case HoverType.TRACK_CONFIG_SHOW_BUTTON: this.showGroup(groupIndex); return; case HoverType.INSIDE_TRACK_HEADER: this.#selectGroup(groupIndex); this.toggleGroupExpand(groupIndex); return; case HoverType.INSIDE_TRACK: case HoverType.OUTSIDE_TRACKS: { this.#selectGroup(groupIndex); 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.ENTRY_INVOKED, this.highlightedEntryIndex); } return; } } } setLinkSelectionAnnotationIsInProgress(inProgress: boolean): void { this.#linkSelectionAnnotationIsInProgress = inProgress; } #selectGroup(groupIndex: number): void { if (groupIndex < 0 || this.selectedGroupIndex === groupIndex) { return; } if (!this.rawTimelineData) { return; } const groups = this.rawTimelineData.groups; if (!groups) { return; } this.keyboardFocusedGroup = groupIndex; // Do not scroll the track if the user is currently selecting an entry for a connection annotation. // Scrolling the view when the entry is being selected results in creating a link with different entry from the one that was clicked on. if (!this.#linkSelectionAnnotationIsInProgress) { this.scrollGroupIntoView(groupIndex); } const groupName = groups[groupIndex].name; if (!groups[groupIndex].selectable) { this.deselectAllGroups(); UI.ARIAUtils.alert(i18nString(UIStrings.sHovered, {PH1: groupName})); } else { this.selectedGroupIndex = groupIndex; this.flameChartDelegate.updateSelectedGroup(this, groups[groupIndex]); this.draw(); UI.ARIAUtils.alert(i18nString(UIStrings.sSelected, {PH1: groupName})); } } private deselectAllGroups(): void { this.selectedGroupIndex = -1; this.flameChartDelegate.updateSelectedGroup(this, null); this.draw(); } private deselectAllEntries(): void { this.selectedEntryIndex = -1; this.rawTimelineData?.emptyInitiators(); this.draw(); } private isGroupFocused(index: number): boolean { return index === this.selectedGroupIndex || 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); } /** * Toggle a group's expanded state. * @param groupIndex - the index of this group in the timelineData.groups * array. Note that this is the array index, and not the startLevel of the * group. */ toggleGroupExpand(groupIndex: number): void { if (groupIndex < 0 || !this.isGroupCollapsible(groupIndex)) { return; } if (!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.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; // Reset all flow arrows when we deselect the entry. this.rawTimelineData.emptyInitiators(); } } } this.updateHeight(); this.draw(); this.#notifyProviderOfConfigurationChange(); 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); } } moveGroupUp(groupIndex: number): void { if (groupIndex < 0) { return; } if (!this.rawTimelineData?.groups) { return; } if (!this.#groupTreeRoot) { return; } for (let i = 0; i < this.#groupTreeRoot.children.length; i++) { const child = this.#groupTreeRoot.children[i]; if (child.index === groupIndex) { // exchange with previous one, only second or later group