| // Copyright 2016 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| 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 Root from '../../core/root/root.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import * as Bindings from '../../models/bindings/bindings.js'; |
| import * as TimelineModel from '../../models/timeline_model/timeline_model.js'; |
| import * as TraceEngine from '../../models/trace/trace.js'; |
| import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| |
| import {CountersGraph} from './CountersGraph.js'; |
| |
| import {Events as PerformanceModelEvents, type PerformanceModel, type WindowChangedEvent} from './PerformanceModel.js'; |
| import {TimelineDetailsView} from './TimelineDetailsView.js'; |
| import {TimelineRegExp} from './TimelineFilters.js'; |
| import { |
| Events as TimelineFlameChartDataProviderEvents, |
| TimelineFlameChartDataProvider, |
| } from './TimelineFlameChartDataProvider.js'; |
| import {TimelineFlameChartNetworkDataProvider} from './TimelineFlameChartNetworkDataProvider.js'; |
| |
| import {type TimelineModeViewDelegate} from './TimelinePanel.js'; |
| |
| import {TimelineSelection} from './TimelineSelection.js'; |
| import {AggregatedTimelineTreeView} from './TimelineTreeView.js'; |
| |
| import {TimelineUIUtils, type TimelineMarkerStyle} from './TimelineUIUtils.js'; |
| |
| const UIStrings = { |
| /** |
| *@description Text in Timeline Flame Chart View of the Performance panel |
| *@example {Frame} PH1 |
| *@example {10ms} PH2 |
| */ |
| sAtS: '{PH1} at {PH2}', |
| }; |
| const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelineFlameChartView.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| export class TimelineFlameChartView extends UI.Widget.VBox implements PerfUI.FlameChart.FlameChartDelegate, |
| UI.SearchableView.Searchable { |
| private readonly delegate: TimelineModeViewDelegate; |
| private model: PerformanceModel|null; |
| private searchResults!: number[]|undefined; |
| private eventListeners: Common.EventTarget.EventDescriptor[]; |
| // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| private readonly showMemoryGraphSetting: Common.Settings.Setting<any>; |
| private readonly networkSplitWidget: UI.SplitWidget.SplitWidget; |
| private mainDataProvider: TimelineFlameChartDataProvider; |
| private readonly mainFlameChart: PerfUI.FlameChart.FlameChart; |
| // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| private readonly networkFlameChartGroupExpansionSetting: Common.Settings.Setting<any>; |
| private networkDataProvider: TimelineFlameChartNetworkDataProvider; |
| private readonly networkFlameChart: PerfUI.FlameChart.FlameChart; |
| private readonly networkPane: UI.Widget.VBox; |
| private readonly splitResizer: HTMLElement; |
| private readonly chartSplitWidget: UI.SplitWidget.SplitWidget; |
| private readonly countersView: CountersGraph; |
| private readonly detailsSplitWidget: UI.SplitWidget.SplitWidget; |
| private readonly detailsView: TimelineDetailsView; |
| private readonly onMainEntrySelected: (event: Common.EventTarget.EventTargetEvent<number>) => void; |
| private readonly onNetworkEntrySelected: (event: Common.EventTarget.EventTargetEvent<number>) => void; |
| private readonly boundRefresh: () => void; |
| #selectedEvents: SDK.TracingModel.CompatibleTraceEvent[]|null; |
| // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| private readonly groupBySetting: Common.Settings.Setting<any>; |
| private searchableView!: UI.SearchableView.SearchableView; |
| private needsResizeToPreferredHeights?: boolean; |
| private selectedSearchResult?: number; |
| private searchRegex?: RegExp; |
| #traceEngineData: TraceEngine.TraceModel.PartialTraceParseDataDuringMigration|null; |
| |
| constructor(delegate: TimelineModeViewDelegate) { |
| super(); |
| this.element.classList.add('timeline-flamechart'); |
| this.delegate = delegate; |
| this.model = null; |
| this.eventListeners = []; |
| this.#traceEngineData = null; |
| |
| this.showMemoryGraphSetting = Common.Settings.Settings.instance().createSetting('timelineShowMemory', false); |
| |
| // Create main and network flamecharts. |
| this.networkSplitWidget = new UI.SplitWidget.SplitWidget(false, false, 'timelineFlamechartMainView', 150); |
| |
| // Ensure that the network panel & resizer appears above the main thread. |
| this.networkSplitWidget.sidebarElement().style.zIndex = '120'; |
| |
| const mainViewGroupExpansionSetting = |
| Common.Settings.Settings.instance().createSetting('timelineFlamechartMainViewGroupExpansion', {}); |
| this.mainDataProvider = new TimelineFlameChartDataProvider(); |
| this.mainDataProvider.addEventListener( |
| TimelineFlameChartDataProviderEvents.DataChanged, () => this.mainFlameChart.scheduleUpdate()); |
| this.mainFlameChart = new PerfUI.FlameChart.FlameChart(this.mainDataProvider, this, mainViewGroupExpansionSetting); |
| this.mainFlameChart.alwaysShowVerticalScroll(); |
| this.mainFlameChart.enableRuler(false); |
| |
| this.networkFlameChartGroupExpansionSetting = |
| Common.Settings.Settings.instance().createSetting('timelineFlamechartNetworkViewGroupExpansion', {}); |
| this.networkDataProvider = new TimelineFlameChartNetworkDataProvider(); |
| this.networkFlameChart = |
| new PerfUI.FlameChart.FlameChart(this.networkDataProvider, this, this.networkFlameChartGroupExpansionSetting); |
| this.networkFlameChart.alwaysShowVerticalScroll(); |
| |
| this.networkPane = new UI.Widget.VBox(); |
| this.networkPane.setMinimumSize(23, 23); |
| this.networkFlameChart.show(this.networkPane.element); |
| this.splitResizer = this.networkPane.element.createChild('div', 'timeline-flamechart-resizer'); |
| this.networkSplitWidget.hideDefaultResizer(true); |
| this.networkSplitWidget.installResizer(this.splitResizer); |
| this.networkSplitWidget.setMainWidget(this.mainFlameChart); |
| this.networkSplitWidget.setSidebarWidget(this.networkPane); |
| |
| // Create counters chart splitter. |
| this.chartSplitWidget = new UI.SplitWidget.SplitWidget(false, true, 'timelineCountersSplitViewState'); |
| this.countersView = new CountersGraph(this.delegate); |
| this.chartSplitWidget.setMainWidget(this.networkSplitWidget); |
| this.chartSplitWidget.setSidebarWidget(this.countersView); |
| this.chartSplitWidget.hideDefaultResizer(); |
| this.chartSplitWidget.installResizer((this.countersView.resizerElement() as Element)); |
| this.updateCountersGraphToggle(); |
| |
| // Create top level properties splitter. |
| this.detailsSplitWidget = new UI.SplitWidget.SplitWidget(false, true, 'timelinePanelDetailsSplitViewState'); |
| this.detailsSplitWidget.element.classList.add('timeline-details-split'); |
| this.detailsView = new TimelineDetailsView(delegate); |
| this.detailsSplitWidget.installResizer(this.detailsView.headerElement()); |
| this.detailsSplitWidget.setMainWidget(this.chartSplitWidget); |
| this.detailsSplitWidget.setSidebarWidget(this.detailsView); |
| this.detailsSplitWidget.show(this.element); |
| |
| this.onMainEntrySelected = this.onEntrySelected.bind(this, this.mainDataProvider); |
| this.onNetworkEntrySelected = this.onEntrySelected.bind(this, this.networkDataProvider); |
| this.mainFlameChart.addEventListener(PerfUI.FlameChart.Events.EntrySelected, this.onMainEntrySelected, this); |
| this.mainFlameChart.addEventListener(PerfUI.FlameChart.Events.EntryInvoked, this.onMainEntrySelected, this); |
| this.networkFlameChart.addEventListener(PerfUI.FlameChart.Events.EntrySelected, this.onNetworkEntrySelected, this); |
| this.networkFlameChart.addEventListener(PerfUI.FlameChart.Events.EntryInvoked, this.onNetworkEntrySelected, this); |
| this.mainFlameChart.addEventListener(PerfUI.FlameChart.Events.EntryHighlighted, this.onEntryHighlighted, this); |
| |
| this.boundRefresh = this.refresh.bind(this); |
| this.#selectedEvents = null; |
| |
| this.mainDataProvider.setEventColorMapping(TimelineUIUtils.eventColor); |
| this.groupBySetting = Common.Settings.Settings.instance().createSetting( |
| 'timelineTreeGroupBy', AggregatedTimelineTreeView.GroupBy.None); |
| this.groupBySetting.addChangeListener(this.updateColorMapper, this); |
| this.updateColorMapper(); |
| } |
| |
| updateColorMapper(): void { |
| if (!this.model) { |
| return; |
| } |
| this.mainDataProvider.setEventColorMapping(TimelineUIUtils.eventColor); |
| this.mainFlameChart.update(); |
| } |
| |
| private onWindowChanged(event: Common.EventTarget.EventTargetEvent<WindowChangedEvent>): void { |
| const {window, animate} = event.data; |
| this.mainFlameChart.setWindowTimes(window.left, window.right, animate); |
| this.networkFlameChart.setWindowTimes(window.left, window.right, animate); |
| this.networkDataProvider.setWindowTimes(window.left, window.right); |
| this.updateSearchResults(false, false); |
| } |
| |
| windowChanged(windowStartTime: number, windowEndTime: number, animate: boolean): void { |
| if (this.model) { |
| this.model.setWindow({left: windowStartTime, right: windowEndTime}, animate); |
| } |
| } |
| |
| updateRangeSelection(startTime: number, endTime: number): void { |
| this.delegate.select(TimelineSelection.fromRange(startTime, endTime)); |
| } |
| |
| updateSelectedGroup(flameChart: PerfUI.FlameChart.FlameChart, group: PerfUI.FlameChart.Group|null): void { |
| if (flameChart !== this.mainFlameChart) { |
| return; |
| } |
| this.#selectedEvents = group ? this.mainDataProvider.groupTreeEvents(group) : null; |
| this.updateTrack(); |
| } |
| |
| setModel( |
| model: PerformanceModel|null, |
| newTraceEngineData: TraceEngine.TraceModel.PartialTraceParseDataDuringMigration|null): void { |
| if (model === this.model) { |
| return; |
| } |
| this.#traceEngineData = newTraceEngineData; |
| Common.EventTarget.removeEventListeners(this.eventListeners); |
| this.model = model; |
| this.#selectedEvents = null; |
| this.mainDataProvider.setModel(this.model, newTraceEngineData); |
| this.networkDataProvider.setModel(this.model); |
| if (this.model) { |
| this.eventListeners = [ |
| this.model.addEventListener(PerformanceModelEvents.WindowChanged, this.onWindowChanged, this), |
| ]; |
| const window = this.model.window(); |
| this.mainFlameChart.setWindowTimes(window.left, window.right); |
| this.networkFlameChart.setWindowTimes(window.left, window.right); |
| this.networkDataProvider.setWindowTimes(window.left, window.right); |
| this.updateSearchResults(false, false); |
| } |
| this.updateColorMapper(); |
| this.updateTrack(); |
| this.refresh(); |
| } |
| |
| private updateTrack(): void { |
| this.countersView.setModel(this.model, this.#selectedEvents); |
| this.detailsView.setModel(this.model, this.#traceEngineData, this.#selectedEvents); |
| } |
| |
| private refresh(): void { |
| if (this.networkDataProvider.isEmpty()) { |
| this.mainFlameChart.enableRuler(true); |
| this.networkSplitWidget.hideSidebar(); |
| } else { |
| this.mainFlameChart.enableRuler(false); |
| this.networkSplitWidget.showBoth(); |
| this.resizeToPreferredHeights(); |
| } |
| this.mainFlameChart.reset(); |
| this.networkFlameChart.reset(); |
| this.updateSearchResults(false, false); |
| } |
| |
| private onEntryHighlighted(commonEvent: Common.EventTarget.EventTargetEvent<number>): void { |
| SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); |
| const entryIndex = commonEvent.data; |
| // TODO(crbug.com/1431166): explore how we can make highlighting agnostic |
| // and take either legacy events, or new trace engine events. Currently if |
| // this highlight comes from a TrackAppender, we create a new legacy event |
| // from the event payload, mainly to satisfy this method. |
| const event = this.mainDataProvider.eventByIndex(entryIndex); |
| if (!event) { |
| return; |
| } |
| const target = this.model && this.model.timelineModel().targetByEvent(event); |
| if (!target) { |
| return; |
| } |
| let backendNodeIds; |
| |
| // Events for tracks that are migrated to the new engine won't use |
| // TimelineModel.TimelineData. |
| if (event instanceof SDK.TracingModel.Event) { |
| const timelineData = TimelineModel.TimelineModel.EventOnTimelineData.forEvent(event); |
| backendNodeIds = timelineData.backendNodeIds; |
| } else if (TraceEngine.Types.TraceEvents.isTraceEventLayoutShift(event)) { |
| const impactedNodes = event.args.data?.impacted_nodes ?? []; |
| backendNodeIds = impactedNodes.map(node => node.node_id); |
| } |
| |
| if (!backendNodeIds) { |
| return; |
| } |
| for (let i = 0; i < backendNodeIds.length; ++i) { |
| new SDK.DOMModel.DeferredDOMNode(target, backendNodeIds[i]).highlight(); |
| } |
| } |
| |
| highlightEvent(event: SDK.TracingModel.Event|null): void { |
| const entryIndex = |
| event ? this.mainDataProvider.entryIndexForSelection(TimelineSelection.fromTraceEvent(event)) : -1; |
| if (entryIndex >= 0) { |
| this.mainFlameChart.highlightEntry(entryIndex); |
| } else { |
| this.mainFlameChart.hideHighlight(); |
| } |
| } |
| |
| override willHide(): void { |
| this.networkFlameChartGroupExpansionSetting.removeChangeListener(this.resizeToPreferredHeights, this); |
| this.showMemoryGraphSetting.removeChangeListener(this.updateCountersGraphToggle, this); |
| Bindings.IgnoreListManager.IgnoreListManager.instance().removeChangeListener(this.boundRefresh); |
| } |
| |
| override wasShown(): void { |
| this.networkFlameChartGroupExpansionSetting.addChangeListener(this.resizeToPreferredHeights, this); |
| this.showMemoryGraphSetting.addChangeListener(this.updateCountersGraphToggle, this); |
| Bindings.IgnoreListManager.IgnoreListManager.instance().addChangeListener(this.boundRefresh); |
| if (this.needsResizeToPreferredHeights) { |
| this.resizeToPreferredHeights(); |
| } |
| this.mainFlameChart.scheduleUpdate(); |
| this.networkFlameChart.scheduleUpdate(); |
| } |
| |
| private updateCountersGraphToggle(): void { |
| if (this.showMemoryGraphSetting.get()) { |
| this.chartSplitWidget.showBoth(); |
| } else { |
| this.chartSplitWidget.hideSidebar(); |
| } |
| } |
| |
| setSelection(selection: TimelineSelection|null): void { |
| let index = this.mainDataProvider.entryIndexForSelection(selection); |
| this.mainFlameChart.setSelectedEntry(index); |
| index = this.networkDataProvider.entryIndexForSelection(selection); |
| this.networkFlameChart.setSelectedEntry(index); |
| if (this.detailsView) { |
| this.detailsView.setSelection(selection); |
| } |
| } |
| |
| private onEntrySelected( |
| dataProvider: PerfUI.FlameChart.FlameChartDataProvider, |
| event: Common.EventTarget.EventTargetEvent<number>): void { |
| const entryIndex = event.data; |
| if (Root.Runtime.experiments.isEnabled('timelineEventInitiators') && dataProvider === this.mainDataProvider) { |
| if (this.mainDataProvider.buildFlowForInitiator(entryIndex)) { |
| this.mainFlameChart.scheduleUpdate(); |
| } |
| } |
| this.delegate.select((dataProvider as TimelineFlameChartNetworkDataProvider | TimelineFlameChartDataProvider) |
| .createSelection(entryIndex)); |
| } |
| |
| resizeToPreferredHeights(): void { |
| if (!this.isShowing()) { |
| this.needsResizeToPreferredHeights = true; |
| return; |
| } |
| this.needsResizeToPreferredHeights = false; |
| this.networkPane.element.classList.toggle( |
| 'timeline-network-resizer-disabled', !this.networkDataProvider.isExpanded()); |
| this.networkSplitWidget.setSidebarSize( |
| this.networkDataProvider.preferredHeight() + this.splitResizer.clientHeight + PerfUI.FlameChart.HeaderHeight + |
| 2); |
| } |
| |
| setSearchableView(searchableView: UI.SearchableView.SearchableView): void { |
| this.searchableView = searchableView; |
| } |
| |
| // UI.SearchableView.Searchable implementation |
| |
| jumpToNextSearchResult(): void { |
| if (!this.searchResults || !this.searchResults.length) { |
| return; |
| } |
| const index = |
| typeof this.selectedSearchResult !== 'undefined' ? this.searchResults.indexOf(this.selectedSearchResult) : -1; |
| this.selectSearchResult(Platform.NumberUtilities.mod(index + 1, this.searchResults.length)); |
| } |
| |
| jumpToPreviousSearchResult(): void { |
| if (!this.searchResults || !this.searchResults.length) { |
| return; |
| } |
| const index = |
| typeof this.selectedSearchResult !== 'undefined' ? this.searchResults.indexOf(this.selectedSearchResult) : 0; |
| this.selectSearchResult(Platform.NumberUtilities.mod(index - 1, this.searchResults.length)); |
| } |
| |
| supportsCaseSensitiveSearch(): boolean { |
| return true; |
| } |
| |
| supportsRegexSearch(): boolean { |
| return true; |
| } |
| |
| private selectSearchResult(index: number): void { |
| this.searchableView.updateCurrentMatchIndex(index); |
| if (this.searchResults) { |
| this.selectedSearchResult = this.searchResults[index]; |
| this.delegate.select(this.mainDataProvider.createSelection(this.selectedSearchResult)); |
| } |
| } |
| |
| private updateSearchResults(shouldJump: boolean, jumpBackwards?: boolean): void { |
| const oldSelectedSearchResult = (this.selectedSearchResult as number); |
| delete this.selectedSearchResult; |
| this.searchResults = []; |
| if (!this.searchRegex || !this.model) { |
| return; |
| } |
| const regExpFilter = new TimelineRegExp(this.searchRegex); |
| const window = this.model.window(); |
| this.searchResults = this.mainDataProvider.search(window.left, window.right, regExpFilter); |
| this.searchableView.updateSearchMatchesCount(this.searchResults.length); |
| if (!shouldJump || !this.searchResults.length) { |
| return; |
| } |
| let selectedIndex = this.searchResults.indexOf(oldSelectedSearchResult); |
| if (selectedIndex === -1) { |
| selectedIndex = jumpBackwards ? this.searchResults.length - 1 : 0; |
| } |
| this.selectSearchResult(selectedIndex); |
| } |
| |
| /** |
| * Returns the indexes of the elements that matched the most recent |
| * query. Elements are indexed by the data provider and correspond |
| * to their position in the data provider entry data array. |
| * Public only for tests. |
| */ |
| getSearchResults(): number[]|undefined { |
| return this.searchResults; |
| } |
| |
| onSearchCanceled(): void { |
| if (typeof this.selectedSearchResult !== 'undefined') { |
| this.delegate.select(null); |
| } |
| delete this.searchResults; |
| delete this.selectedSearchResult; |
| delete this.searchRegex; |
| } |
| |
| performSearch(searchConfig: UI.SearchableView.SearchConfig, shouldJump: boolean, jumpBackwards?: boolean): void { |
| this.searchRegex = searchConfig.toSearchRegex().regex; |
| this.updateSearchResults(shouldJump, jumpBackwards); |
| } |
| } |
| |
| export class Selection { |
| timelineSelection: TimelineSelection; |
| entryIndex: number; |
| constructor(selection: TimelineSelection, entryIndex: number) { |
| this.timelineSelection = selection; |
| this.entryIndex = entryIndex; |
| } |
| } |
| |
| export const FlameChartStyle = { |
| textColor: '#333', |
| }; |
| |
| export class TimelineFlameChartMarker implements PerfUI.FlameChart.FlameChartMarker { |
| private readonly startTimeInternal: number; |
| private readonly startOffset: number; |
| private style: TimelineMarkerStyle; |
| constructor(startTime: number, startOffset: number, style: TimelineMarkerStyle) { |
| this.startTimeInternal = startTime; |
| this.startOffset = startOffset; |
| this.style = style; |
| } |
| |
| startTime(): number { |
| return this.startTimeInternal; |
| } |
| |
| color(): string { |
| return this.style.color; |
| } |
| |
| title(): string|null { |
| if (this.style.lowPriority) { |
| return null; |
| } |
| const startTime = i18n.TimeUtilities.millisToString(this.startOffset); |
| return i18nString(UIStrings.sAtS, {PH1: this.style.title, PH2: startTime}); |
| } |
| |
| draw(context: CanvasRenderingContext2D, x: number, height: number, pixelsPerMillisecond: number): void { |
| const lowPriorityVisibilityThresholdInPixelsPerMs = 4; |
| |
| if (this.style.lowPriority && pixelsPerMillisecond < lowPriorityVisibilityThresholdInPixelsPerMs) { |
| return; |
| } |
| |
| context.save(); |
| if (this.style.tall) { |
| context.strokeStyle = this.style.color; |
| context.lineWidth = this.style.lineWidth; |
| context.translate(this.style.lineWidth < 1 || (this.style.lineWidth & 1) ? 0.5 : 0, 0.5); |
| context.beginPath(); |
| context.moveTo(x, 0); |
| context.setLineDash(this.style.dashStyle); |
| context.lineTo(x, context.canvas.height); |
| context.stroke(); |
| } |
| context.restore(); |
| } |
| } |
| |
| // TODO(crbug.com/1167717): Make this a const enum again |
| // eslint-disable-next-line rulesdir/const_enum |
| export enum ColorBy { |
| URL = 'URL', |
| } |