// Copyright 2024 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable @devtools/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 AIAssistance from '../../../models/ai_assistance/ai_assistance.js'; import * as Trace from '../../../models/trace/trace.js'; import type * as PerfUI from '../../../ui/legacy/components/perf_ui/perf_ui.js'; import * as UI from '../../../ui/legacy/legacy.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import * as Components from './components/components.js'; import type {SectionPosition} from './components/TimespanBreakdownOverlay.js'; const UIStrings = { /** * @description Text for showing that a metric was observed in the local environment. * @example {LCP} PH1 */ fieldMetricMarkerLocal: '{PH1} - Local', /** * @description Text for showing that a metric was observed in the field, from real use data (CrUX). Also denotes if from URL or Origin dataset. * @example {LCP} PH1 * @example {URL} PH2 */ fieldMetricMarkerField: '{PH1} - Field ({PH2})', /** * @description Label for an option that selects the page's specific URL as opposed to it's entire origin/domain. */ urlOption: 'URL', /** * @description Label for an option that selects the page's entire origin/domain as opposed to it's specific URL. */ originOption: 'Origin', } as const; const str_ = i18n.i18n.registerUIStrings('panels/timeline/overlays/OverlaysImpl.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); /** * Below the network track there is a resize bar the user can click and drag. */ const NETWORK_RESIZE_ELEM_HEIGHT_PX = 8; /** * Represents which flamechart an entry is rendered in. * We need to know this because when we place an overlay for an entry we need * to adjust its Y value if it's in the main chart which is drawn below the * network chart */ export type EntryChartLocation = 'main'|'network'; /** * Given a list of overlays, this method will calculate the smallest possible * trace window that will contain all of the overlays. * `overlays` is expected to be non-empty, and this will return `null` if it is empty. */ export function traceWindowContainingOverlays(overlays: Trace.Types.Overlays.Overlay[]): Trace.Types.Timing.TraceWindowMicro|null { const windows = overlays.map(Trace.Helpers.Timing.traceWindowFromOverlay).filter(b => !!b); return Trace.Helpers.Timing.combineTraceWindowsMicro(windows); } /** * Get a list of entries for a given overlay. */ export function entriesForOverlay(overlay: Trace.Types.Overlays.Overlay): readonly Trace.Types.Overlays.OverlayEntry[] { const entries: Trace.Types.Overlays.OverlayEntry[] = []; switch (overlay.type) { case 'ENTRY_SELECTED': { entries.push(overlay.entry); break; } case 'ENTRY_OUTLINE': { entries.push(overlay.entry); break; } case 'TIME_RANGE': { // Time ranges are not associated with entries. break; } case 'ENTRY_LABEL': { entries.push(overlay.entry); break; } case 'ENTRIES_LINK': { entries.push(overlay.entryFrom); if (overlay.entryTo) { entries.push(overlay.entryTo); } break; } case 'TIMESPAN_BREAKDOWN': { if (overlay.entry) { entries.push(overlay.entry); } break; } case 'TIMESTAMP_MARKER': { // This overlay type isn't associated to any entry, so just break here. break; } case 'CANDY_STRIPED_TIME_RANGE': { entries.push(overlay.entry); break; } case 'TIMINGS_MARKER': { entries.push(...overlay.entries); break; } case 'BOTTOM_INFO_BAR': break; default: Platform.assertNever(overlay, `Unknown overlay type ${JSON.stringify(overlay)}`); } return entries; } export function chartForEntry(entry: Trace.Types.Overlays.OverlayEntry): EntryChartLocation { if (Trace.Types.Events.isNetworkTrackEntry(entry)) { return 'network'; } return 'main'; } export interface TimelineOverlaySetOptions { /** Whether to update the trace window. Defaults to false. */ updateTraceWindow?: boolean; /** * If updateTraceWindow is true, this is the total amount of space added as margins to the * side of the bounds represented by the overlays, represented as a percentage relative to * the width of the overlay bounds. The space is split evenly on either side of the overlay * bounds. The intention is to neatly center the overlays in the middle of the viewport, with * some additional context on either side. * * If 0, no margins will be added, and the precise bounds defined by the overlays will be used. * * If not provided, 100 is used (25% margin, 50% overlays, 25% margin). */ updateTraceWindowPercentage?: number; } /** * Denotes overlays that are singletons; only one of these will be allowed to * exist at any given time. If one exists and the add() method is called, the * new overlay will replace the existing one. */ type SingletonOverlay = Trace.Types.Overlays.EntrySelected|Trace.Types.Overlays.TimestampMarker; export function overlayIsSingleton(overlay: Trace.Types.Overlays.Overlay): overlay is SingletonOverlay { return overlayTypeIsSingleton(overlay.type); } export function overlayTypeIsSingleton(type: Trace.Types.Overlays.Overlay['type']): type is SingletonOverlay['type'] { return type === 'TIMESTAMP_MARKER' || type === 'ENTRY_SELECTED' || type === 'BOTTOM_INFO_BAR'; } /** * To be able to draw overlays accurately at the correct pixel position, we * need a variety of pixel values from both flame charts (Network and "Rest"). * As each FlameChart draws, it emits an event with its latest set of * dimensions. That updates the Overlays and causes them to redraw. * Note that we can't use the visible trace window from the TraceBounds * service as that can get out of sync with rapid FlameChart draws. To ensure * we draw overlays smoothly as the FlameChart renders we use the latest values * provided to us from the FlameChart. In `FlameChart#draw` we dispatch an * event containing the latest dimensions, and those are passed into the * Overlays system via TimelineFlameChartView. */ interface ActiveDimensions { trace: { visibleWindow: Trace.Types.Timing.TraceWindowMicro|null, }; charts: { main: FlameChartDimensions|null, network: FlameChartDimensions|null, }; } /** * The dimensions each flame chart reports. Note that in the current UI they * will always have the same width, so theoretically we could only gather that * from one chart, but we gather it from both for simplicity and to cover us in * the future should the UI change and the charts have different widths. */ interface FlameChartDimensions { widthPixels: number; heightPixels: number; scrollOffsetPixels: number; // If every single group (e.g. track) within the chart is collapsed or not. // This matters because in the network track if every group (there is only // one) is collapsed, there is no resizer bar shown, which impacts our pixel // calculations for overlay positioning. allGroupsCollapsed: boolean; } export interface TimelineCharts { mainChart: PerfUI.FlameChart.FlameChart; mainProvider: PerfUI.FlameChart.FlameChartDataProvider; networkChart: PerfUI.FlameChart.FlameChart; networkProvider: PerfUI.FlameChart.FlameChartDataProvider; } export interface OverlayEntryQueries { parsedTrace: () => Trace.TraceModel.ParsedTrace | null; isEntryCollapsedByUser: (entry: Trace.Types.Events.Event) => boolean; firstVisibleParentForEntry: (entry: Trace.Types.Events.Event) => Trace.Types.Events.Event | null; } /** * An event dispatched when one of the Annotation Overlays (overlay created by the user, * ex. Trace.Types.Overlays.EntryLabel) is removed or updated. When one of the Annotation Overlays is removed or updated, * ModificationsManager listens to this event and updates the current annotations. **/ export type UpdateAction = 'Remove'|'Update'; export class AnnotationOverlayActionEvent extends Event { static readonly eventName = 'annotationoverlayactionsevent'; constructor(public overlay: Trace.Types.Overlays.Overlay, public action: UpdateAction) { super(AnnotationOverlayActionEvent.eventName); } } export class ConsentDialogVisibilityChange extends Event { static readonly eventName = 'consentdialogvisibilitychange'; constructor(public isVisible: boolean) { super(ConsentDialogVisibilityChange.eventName, {bubbles: true, composed: true}); } } export class TimeRangeMouseOverEvent extends Event { static readonly eventName = 'timerangemouseoverevent'; constructor(public overlay: Trace.Types.Overlays.TimeRangeLabel) { super(TimeRangeMouseOverEvent.eventName, {bubbles: true}); } } export class TimeRangeMouseOutEvent extends Event { static readonly eventName = 'timerangemouseoutevent'; constructor() { super(TimeRangeMouseOutEvent.eventName, {bubbles: true}); } } export class EntryLabelMouseClick extends Event { static readonly eventName = 'entrylabelmouseclick'; constructor(public overlay: Trace.Types.Overlays.EntryLabel) { super(EntryLabelMouseClick.eventName, {composed: true, bubbles: true}); } } interface EntriesLinkVisibleEntries { entryFrom: Trace.Types.Events.Event; entryTo: Trace.Types.Events.Event|undefined; entryFromIsSource: boolean; entryToIsSource: boolean; } export class EventReferenceClick extends Event { static readonly eventName = 'eventreferenceclick'; constructor(public event: Trace.Types.Events.Event) { super(EventReferenceClick.eventName, {bubbles: true, composed: true}); } } /** * This class manages all the overlays that get drawn onto the performance * timeline. Overlays are DOM and are drawn above the network and main flame * chart. * * For more documentation, see `timeline/README.md` which has a section on overlays. */ export class Overlays extends EventTarget { /** * The list of active overlays. Overlays can't be marked as visible or * hidden; every overlay in this list is rendered. * We track each overlay against the HTML Element we have rendered. This is * because on first render of a new overlay, we create it, but then on * subsequent renders we do not destroy and recreate it, instead we update it * based on the new position of the timeline. */ #overlaysToElements = new Map(); #singletonOverlays = new Map(); // When the Entries Link Annotation is created, the arrow needs to follow the mouse. // Update the mouse coordinates while it is being created. #lastMouseOffsetX: number|null = null; #lastMouseOffsetY: number|null = null; // `entriesLinkInProgress` is the entries link Overlay that has not yet been fully created // and only has the entry that the link starts from set. // We save it as a separate variable because when the second entry of the link is not chosen yet, // the arrow follows the mouse. To achieve that, update the coordinates of `entriesLinkInProgress` // on mousemove. There can only be one link in the process on being created so the mousemove // only needs to update `entriesLinkInProgress` link overlay. #entriesLinkInProgress: Trace.Types.Overlays.EntriesLink|null; #dimensions: ActiveDimensions = { trace: { visibleWindow: null, }, charts: { main: null, network: null, }, }; /** * To calculate the Y pixel value for an event we need access to the chart * and data provider in order to find out what level the event is on, and from * there calculate the pixel value for that level. */ #charts: TimelineCharts; /** * The Overlays class will take each overlay, generate its HTML, and add it * to the container. This container is provided for us when the class is * created so we can manage its contents as overlays come and go. */ #overlaysContainer: HTMLElement; // Setting that specified if the annotations overlays need to be visible. // It is switched on/off from the annotations tab in the sidebar. readonly #annotationsHiddenSetting: Common.Settings.Setting; /** * The OverlaysManager sometimes needs to find out if an entry is visible or * not, and if not, why not - for example, if the user has collapsed its * parent. We define these query functions that must be supplied in order to * answer these questions. */ #queries: OverlayEntryQueries; constructor(init: { container: HTMLElement, flameChartsContainers: { main: HTMLElement, network: HTMLElement, }, charts: TimelineCharts, entryQueries: OverlayEntryQueries, }) { super(); this.#overlaysContainer = init.container; this.#charts = init.charts; this.#queries = init.entryQueries; this.#entriesLinkInProgress = null; this.#annotationsHiddenSetting = Common.Settings.Settings.instance().moduleSetting('annotations-hidden'); this.#annotationsHiddenSetting.addChangeListener(this.update.bind(this)); // HTMLElements of both Flamecharts. They are used to get the mouse position over the Flamecharts. init.flameChartsContainers.main.addEventListener( 'mousemove', event => this.#updateMouseCoordinatesProgressEntriesLink.bind(this)(event, 'main')); init.flameChartsContainers.network.addEventListener( 'mousemove', event => this.#updateMouseCoordinatesProgressEntriesLink.bind(this)(event, 'network')); } // Toggle display of the whole OverlaysContainer. // This function is used to hide all overlays when the Flamechart is in the 'reorder tracks' state. // If the tracks are being reordered, they are collapsed and we do not want to display // anything except the tracks reordering interface. // // Do not change individual overlays visibility with 'setOverlayElementVisibility' since we do not // want to overwrite the overlays visibility state that was set before entering the reordering state. toggleAllOverlaysDisplayed(allOverlaysDisplayed: boolean): void { this.#overlaysContainer.style.display = allOverlaysDisplayed ? 'block' : 'none'; } // Mousemove event listener to get mouse coordinates and update them for the entries link that is being created. // // The 'mousemove' event is attached to `flameChartsContainers` instead of `overlaysContainer` // because `overlaysContainer` doesn't have events to enable the interaction with the // Flamecharts beneath it. #updateMouseCoordinatesProgressEntriesLink(event: Event, chart: EntryChartLocation): void { if (this.#entriesLinkInProgress?.state !== Trace.Types.File.EntriesLinkState.PENDING_TO_EVENT) { return; } const mouseEvent = (event as MouseEvent); this.#lastMouseOffsetX = mouseEvent.offsetX; this.#lastMouseOffsetY = mouseEvent.offsetY; // The Overlays layer coordinates cover both Network and Main Charts, while the mousemove // coordinates are received from the charts individually and start from 0 for each chart. // // To make it work on the overlays, we need to know which chart the entry belongs to and, // if it is on the main chart, add the height of the Network chart to get correct Entry // coordinates on the Overlays layer. const networkHeight = this.#dimensions.charts.network?.heightPixels ?? 0; const linkInProgressElement = this.#overlaysToElements.get(this.#entriesLinkInProgress); if (linkInProgressElement) { const component = linkInProgressElement.querySelector('devtools-entries-link-overlay') as Components.EntriesLinkOverlay.EntriesLinkOverlay; const yCoordinate = mouseEvent.offsetY + ((chart === 'main') ? networkHeight : 0); component.toEntryCoordinateAndDimensions = {x: mouseEvent.offsetX, y: yCoordinate}; } } /** * Add a new overlay to the view. */ add(newOverlay: T): T { if (this.#overlaysToElements.has(newOverlay)) { return newOverlay; } /** * If the overlay type is a singleton, and we already have one, we update * the existing one, rather than create a new one. This ensures you can only * ever have one instance of the overlay type. */ if (overlayIsSingleton(newOverlay)) { const existing = this.#singletonOverlays.get(newOverlay.type); if (existing) { this.updateExisting(existing, newOverlay); return existing as T; // The is a safe cast, thanks to `type` above. } this.#singletonOverlays.set(newOverlay.type, newOverlay); } // By setting the value to null, we ensure that on the next render that the // overlay will have a new HTML element created for it. this.#overlaysToElements.set(newOverlay, null); return newOverlay; } /** * Update an existing overlay without destroying and recreating its * associated DOM. * * This is useful if you need to rapidly update an overlay's data - e.g. * dragging to create time ranges - without the thrashing of destroying the * old overlay and re-creating the new one. */ updateExisting(existingOverlay: T, newData: Partial): void { if (!this.#overlaysToElements.has(existingOverlay)) { console.error('Trying to update an overlay that does not exist.'); return; } for (const [key, value] of Object.entries(newData)) { // newData is of type Partial, so each key must exist in T, but // Object.entries doesn't carry that information. const k = key as keyof T; existingOverlay[k] = value; } } enterLabelEditMode(overlay: Trace.Types.Overlays.EntryLabel): void { // Entry edit state can be triggered from outside the label component by clicking on the // Entry that already has a label. Instead of creating a new label, set the existing entry // label into an editable state. const element = this.#overlaysToElements.get(overlay); const component = element?.querySelector('devtools-entry-label-overlay'); if (component) { component.setLabelEditabilityAndRemoveEmptyLabel(true); } } bringLabelForward(overlay: Trace.Types.Overlays.EntryLabel): void { // Before bringing the element forward, remove the 'bring-forward' class from all the other elements for (const element of this.#overlaysToElements.values()) { element?.classList.remove('bring-forward'); } const element = this.#overlaysToElements.get(overlay); element?.classList.add('bring-forward'); } /** * @returns the list of overlays associated with a given entry. */ overlaysForEntry(entry: Trace.Types.Overlays.OverlayEntry): Trace.Types.Overlays.Overlay[] { const matches: Trace.Types.Overlays.Overlay[] = []; for (const [overlay] of this.#overlaysToElements) { if ('entry' in overlay && overlay.entry === entry) { matches.push(overlay); } } return matches; } /** * Used for debugging and testing. Do not mutate the element directly using * this method. */ elementForOverlay(overlay: Trace.Types.Overlays.Overlay): HTMLElement|null { return this.#overlaysToElements.get(overlay) ?? null; } /** * Removes any active overlays that match the provided type. * @returns the number of overlays that were removed. */ removeOverlaysOfType(type: Trace.Types.Overlays.Overlay['type']): number { if (overlayTypeIsSingleton(type)) { const singleton = this.#singletonOverlays.get(type); if (singleton) { this.remove(singleton); return 1; } return 0; } const overlaysToRemove = Array.from(this.#overlaysToElements.keys()).filter(overlay => { return overlay.type === type; }); for (const overlay of overlaysToRemove) { this.remove(overlay); } return overlaysToRemove.length; } /** * @returns all overlays that match the provided type. */ overlaysOfType(type: T['type']): Array> { if (overlayTypeIsSingleton(type)) { const singleton = this.#singletonOverlays.get(type); if (singleton) { return [singleton as T]; } return []; } const matches: T[] = []; function overlayIsOfType(overlay: Trace.Types.Overlays.Overlay): overlay is T { return overlay.type === type; } for (const [overlay] of this.#overlaysToElements) { if (overlayIsOfType(overlay)) { matches.push(overlay); } } return matches; } /** * @returns all overlays. */ allOverlays(): Trace.Types.Overlays.Overlay[] { return [...this.#overlaysToElements.keys()]; } /** * Removes the provided overlay from the list of overlays and destroys any * DOM associated with it. */ remove(overlay: Trace.Types.Overlays.Overlay): void { const htmlElement = this.#overlaysToElements.get(overlay); if (htmlElement && this.#overlaysContainer) { this.#overlaysContainer.removeChild(htmlElement); } this.#overlaysToElements.delete(overlay); if (overlayIsSingleton(overlay)) { this.#singletonOverlays.delete(overlay.type); } } /** * Update the dimensions of a chart. * IMPORTANT: this does not trigger a re-draw. You must call the render() method manually. */ updateChartDimensions(chart: EntryChartLocation, dimensions: FlameChartDimensions): void { this.#dimensions.charts[chart] = dimensions; } /** * Update the visible window of the UI. * IMPORTANT: this does not trigger a re-draw. You must call the render() method manually. */ updateVisibleWindow(visibleWindow: Trace.Types.Timing.TraceWindowMicro): void { this.#dimensions.trace.visibleWindow = visibleWindow; } /** * Clears all overlays and all data. Call this when the trace is changing * (e.g. the user has imported/recorded a new trace) and we need to start from * scratch and remove all overlays relating to the previous trace. */ reset(): void { if (this.#overlaysContainer) { this.#overlaysContainer.innerHTML = ''; } this.#overlaysToElements.clear(); this.#singletonOverlays.clear(); // Clear out dimensions from the old Flame Charts. this.#dimensions.trace.visibleWindow = null; this.#dimensions.charts.main = null; this.#dimensions.charts.network = null; } /** * Updates the Overlays UI: new overlays will be rendered onto the view, and * existing overlays will have their positions changed to ensure they are * rendered in the right place. */ async update(): Promise { const timeRangeOverlays: Trace.Types.Overlays.TimeRangeLabel[] = []; for (const [overlay, existingElement] of this.#overlaysToElements) { const element = existingElement || this.#createElementForNewOverlay(overlay); if (!existingElement) { // This is a new overlay, so we have to store the element and add it to the DOM. this.#overlaysToElements.set(overlay, element); this.#overlaysContainer.appendChild(element); } // A chance to update the overlay before we re-position it. If an // overlay's data changed, this is where we can pass that data into the // overlay's component so it has the latest data. this.#updateOverlayBeforePositioning(overlay, element); // Now we position the overlay on the timeline. this.#positionOverlay(overlay, element); // And now we give every overlay a chance to react to its new position, // if it needs to this.#updateOverlayAfterPositioning(overlay, element); if (overlay.type === 'TIME_RANGE') { timeRangeOverlays.push(overlay); } } if (timeRangeOverlays.length > 1) { // If there are 0 or 1 overlays, they can't overlap this.#positionOverlappingTimeRangeLabels(timeRangeOverlays); } } /** * If any time-range overlays overlap, we try to adjust their horizontal * position in order to make sure you can distinguish them and that the labels * do not entirely overlap. * This is very much minimal best effort, and does not guarantee that all * labels will remain readable. */ #positionOverlappingTimeRangeLabels(overlays: readonly Trace.Types.Overlays.TimeRangeLabel[]): void { const overlaysSorted = overlays.toSorted((o1, o2) => { return o1.bounds.min - o2.bounds.min; }); // Track the overlays which overlap other overlays. // This isn't bi-directional: if we find that O2 overlaps O1, we will // store O1 => [O2]. We will not then also store O2 => [O1], because we // only need to deal with the overlap once. const overlapsByOverlay = new Map(); for (let i = 0; i < overlaysSorted.length; i++) { const current = overlaysSorted[i]; const overlaps: Trace.Types.Overlays.TimeRangeLabel[] = []; // Walk through subsequent overlays and find stop when you find the next one that does not overlap. for (let j = i + 1; j < overlaysSorted.length; j++) { const next = overlaysSorted[j]; const currentAndNextOverlap = Trace.Helpers.Timing.boundsIncludeTimeRange({ bounds: current.bounds, timeRange: next.bounds, }); if (currentAndNextOverlap) { overlaps.push(next); } else { // Overlays are sorted by time, if this one does not overlap, the next one will not, so we can break. break; } } overlapsByOverlay.set(current, overlaps); } for (const [firstOverlay, overlappingOverlays] of overlapsByOverlay) { const element = this.#overlaysToElements.get(firstOverlay); if (!element) { continue; } // If the first overlay is adjusted, we can start back from 0 again // rather than continually increment up. let firstIndexForOverlapClass = 1; if (element.getAttribute('class')?.includes('overlap-')) { firstIndexForOverlapClass = 0; } overlappingOverlays.forEach(overlay => { const element = this.#overlaysToElements.get(overlay); element?.classList.add(`overlap-${firstIndexForOverlapClass++}`); }); } } #positionOverlay(overlay: Trace.Types.Overlays.Overlay, element: HTMLElement): void { const annotationsAreHidden = this.#annotationsHiddenSetting.get(); switch (overlay.type) { case 'ENTRY_SELECTED': { const isVisible = this.entryIsVisibleOnChart(overlay.entry); this.#setOverlayElementVisibility(element, isVisible); if (isVisible) { this.#positionEntryBorderOutlineType(overlay.entry, element); } break; } case 'ENTRY_OUTLINE': { if (this.entryIsVisibleOnChart(overlay.entry)) { this.#setOverlayElementVisibility(element, true); this.#positionEntryBorderOutlineType(overlay.entry, element); } else { this.#setOverlayElementVisibility(element, false); } break; } case 'TIME_RANGE': { // The time range annotation can also be used to measure a selection in the timeline and is not saved if no label is added. // Therefore, we only care about the annotation hidden setting if the time range has a label. if (overlay.label.length) { this.#setOverlayElementVisibility(element, !annotationsAreHidden); } this.#positionTimeRangeOverlay(overlay, element); break; } case 'ENTRY_LABEL': { const entryVisible = this.entryIsVisibleOnChart(overlay.entry); this.#setOverlayElementVisibility(element, entryVisible && !annotationsAreHidden); if (entryVisible) { const entryLabelVisibleHeight = this.#positionEntryLabelOverlay(overlay, element); const component = element.querySelector('devtools-entry-label-overlay'); if (component && entryLabelVisibleHeight) { component.entryLabelVisibleHeight = entryLabelVisibleHeight; } } break; } case 'ENTRIES_LINK': { // The exact entries that are linked to could be collapsed in a flame // chart, so we figure out the best visible entry pairs to draw // between. const entriesToConnect = this.#calculateFromAndToForEntriesLink(overlay); const isVisible = entriesToConnect !== null && !annotationsAreHidden; this.#setOverlayElementVisibility(element, isVisible); if (isVisible) { this.#positionEntriesLinkOverlay(overlay, element, entriesToConnect); } break; } case 'TIMESPAN_BREAKDOWN': { this.#positionTimespanBreakdownOverlay(overlay, element); // TODO: Have the timespan squeeze instead. if (overlay.entry) { const {visibleWindow} = this.#dimensions.trace; const isVisible = Boolean( visibleWindow && this.#entryIsVerticallyVisibleOnChart(overlay.entry) && Trace.Helpers.Timing.boundsIncludeTimeRange({ bounds: visibleWindow, timeRange: overlay.sections[0].bounds, }), ); this.#setOverlayElementVisibility(element, isVisible); } break; } case 'TIMESTAMP_MARKER': { const {visibleWindow} = this.#dimensions.trace; // Only update the position if the timestamp of this marker is within // the visible bounds. const isVisible = Boolean(visibleWindow && Trace.Helpers.Timing.timestampIsInBounds(visibleWindow, overlay.timestamp)); this.#setOverlayElementVisibility(element, isVisible); if (isVisible) { this.#positionTimingOverlay(overlay, element); } break; } case 'CANDY_STRIPED_TIME_RANGE': { const {visibleWindow} = this.#dimensions.trace; // If the bounds of this overlay are not within the visible bounds, we // can skip updating its position and just hide it. const isVisible = Boolean( visibleWindow && this.#entryIsVerticallyVisibleOnChart(overlay.entry) && Trace.Helpers.Timing.boundsIncludeTimeRange({ bounds: visibleWindow, timeRange: overlay.bounds, })); this.#setOverlayElementVisibility(element, isVisible); if (isVisible) { this.#positionCandyStripedTimeRange(overlay, element); } break; } case 'TIMINGS_MARKER': { const {visibleWindow} = this.#dimensions.trace; // All the entries have the same ts, so can use the first. const isVisible = Boolean(visibleWindow && this.#entryIsHorizontallyVisibleOnChart(overlay.entries[0])); this.#setOverlayElementVisibility(element, isVisible); if (isVisible) { this.#positionTimingOverlay(overlay, element); } break; } case 'BOTTOM_INFO_BAR': { this.#positionInfoBarBanner(overlay, element); break; } default: { Platform.TypeScriptUtilities.assertNever(overlay, `Unknown overlay: ${JSON.stringify(overlay)}`); } } } #positionInfoBarBanner( overlay: Trace.Types.Overlays.BottomInfoBar, element: HTMLElement, ): void { const mainChart = this.#dimensions.charts.main; if (!mainChart) { this.#setOverlayElementVisibility(element, false); return; } /* * This calculation determines how many pixels of the bottom-positioned element * (the banner) are visible within a scrollable container. * The logic works by first calculating the number of pixels that are hidden * below the current scroll position, and then subtracting that value from * the total height of the banner. * 1. totalHeight - (mainChart.scrollOffsetPixels + mainChart.heightPixels): * Calculates the number of pixels of content that are hidden below the * bottom of the viewport. * 2. defaultBannerHeight - (hidden pixels): * Subtracts the hidden pixels from the banner's total height to find * the remaining, visible portion. */ // By default an Infobar is 40px high. But when it comes to rendering it // might be higher if the infobar is wrapped; so we adjust the actual // number of visible pixels later on. // We can't use the real value in the calculation because when its hidden // it has a height of 0, which means we'd never calculate the right values. const defaultBannerHeight = 40; const totalHeight = this.#charts.mainChart.totalContentHeight(); const pixelsHiddenBelowViewport = totalHeight - (mainChart.scrollOffsetPixels + mainChart.heightPixels); const visiblePixelsOfBanner = defaultBannerHeight - pixelsHiddenBelowViewport; if (visiblePixelsOfBanner <= 0) { this.#setOverlayElementVisibility(element, false); return; } this.#setOverlayElementVisibility(element, true); // Now we adjust our calculation based on the actual size of the infobar // (it has height as now it's visible on the screen) // We do this by removing the default banner height (to reset our // calculation back to "0") and adding the actual height. const actualBannerHeight = overlay.infobar.element.clientHeight; const adjustedVisiblePixels = visiblePixelsOfBanner - defaultBannerHeight + actualBannerHeight; // Use Math.min here to ensure the infobar never grows beyond the size it // needs to be. Without this we make the infobar fill all available space // in the canvas, but we want it to stay the right size and stuck to the // bottom. element.style.height = `${Math.min(adjustedVisiblePixels, actualBannerHeight)}px`; // So it doesn't overlap the right scrollbar. if (this.#charts.mainChart.verticalScrollBarVisible()) { element.style.right = '11px'; } else { element.style.right = '0'; } } #positionTimingOverlay( overlay: Trace.Types.Overlays.TimestampMarker|Trace.Types.Overlays.TimingsMarker, element: HTMLElement): void { let left; switch (overlay.type) { case 'TIMINGS_MARKER': { // All the entries have the same ts, so can use the first. const timings = Trace.Helpers.Timing.eventTimingsMicroSeconds(overlay.entries[0]); left = this.#xPixelForMicroSeconds('main', timings.startTime); break; } case 'TIMESTAMP_MARKER': { // Because we are adjusting the x position, we can use either chart here. left = this.#xPixelForMicroSeconds('main', overlay.timestamp); break; } } element.style.left = `${left}px`; } #positionTimespanBreakdownOverlay(overlay: Trace.Types.Overlays.TimespanBreakdown, element: HTMLElement): void { if (overlay.sections.length === 0) { return; } const component = element.querySelector('.devtools-timespan-breakdown-overlay'); if (!component) { return; } const widget = UI.Widget.Widget.get(component) as Components.TimespanBreakdownOverlay.TimespanBreakdownOverlay; // Handle horizontal positioning. const leftEdgePixel = this.#xPixelForMicroSeconds('main', overlay.sections[0].bounds.min); const rightEdgePixel = this.#xPixelForMicroSeconds('main', overlay.sections[overlay.sections.length - 1].bounds.max); if (leftEdgePixel === null || rightEdgePixel === null) { return; } const rangeWidth = rightEdgePixel - leftEdgePixel; widget.left = leftEdgePixel; widget.width = rangeWidth; const widths: SectionPosition[] = []; for (const section of overlay.sections) { const leftPixel = this.#xPixelForMicroSeconds('main', section.bounds.min); const rightPixel = this.#xPixelForMicroSeconds('main', section.bounds.max); if (leftPixel === null || rightPixel === null) { return; } const rangeWidth = rightPixel - leftPixel; widths.push({left: leftPixel, width: rangeWidth}); } widget.widths = widths; // Handle vertical positioning based on the entry's vertical position. if (overlay.entry && (overlay.renderLocation === 'BELOW_EVENT' || overlay.renderLocation === 'ABOVE_EVENT')) { // Max height for the overlay box when attached to an entry. const MAX_BOX_HEIGHT = 50; widget.maxHeight = MAX_BOX_HEIGHT; const y = this.yPixelForEventOnChart(overlay.entry); if (y === null) { return; } const eventHeight = this.pixelHeightForEventOnChart(overlay.entry); if (eventHeight === null) { return; } if (overlay.renderLocation === 'BELOW_EVENT') { const top = y + eventHeight; widget.top = top; } else { // Some padding so the box hovers just on top. const PADDING = 7; // Where the timespan breakdown should sit. Slightly on top of the entry. const bottom = y - PADDING; // Available space between the bottom of the overlay and top of the chart. const minSpace = Math.max(bottom, 0); // Constrain height to available space. const height = Math.min(MAX_BOX_HEIGHT, minSpace); const top = bottom - height; widget.top = top; } } } /** * Positions the arrow between two entries. Takes in the entriesToConnect * because if one of the original entries is hidden in a collapsed main thread * icicle, we use its parent to connect to. */ #positionEntriesLinkOverlay( overlay: Trace.Types.Overlays.EntriesLink, element: HTMLElement, entriesToConnect: EntriesLinkVisibleEntries): void { const component = element.querySelector('devtools-entries-link-overlay'); if (component) { const fromEntryInCollapsedTrack = this.#entryIsInCollapsedTrack(entriesToConnect.entryFrom); const toEntryInCollapsedTrack = entriesToConnect.entryTo && this.#entryIsInCollapsedTrack(entriesToConnect.entryTo); const bothEntriesInCollapsedTrack = Boolean(fromEntryInCollapsedTrack && toEntryInCollapsedTrack); // If both entries are in collapsed tracks, we hide the overlay completely. if (bothEntriesInCollapsedTrack) { this.#setOverlayElementVisibility(element, false); return; } // If either entry (but not both) is in a track that the user has collapsed, we do not // show the connection at all, but we still show the borders around // the entry. So in this case we mark the overlay as visible, but // tell it to not draw the arrow. const hideArrow = Boolean(fromEntryInCollapsedTrack || toEntryInCollapsedTrack); component.hideArrow = hideArrow; const {entryFrom, entryTo, entryFromIsSource, entryToIsSource} = entriesToConnect; const entryFromWrapper = component.entryFromWrapper(); // Should not happen, the 'from' wrapper should always exist. Something went wrong, return in this case. if (!entryFromWrapper) { return; } const entryFromVisibility = this.entryIsVisibleOnChart(entryFrom) && !fromEntryInCollapsedTrack; const entryToVisibility = entryTo ? this.entryIsVisibleOnChart(entryTo) && !toEntryInCollapsedTrack : false; // If the entry is not currently visible, draw the arrow to the edge of the screen towards the entry on the Y-axis. let fromEntryX = 0; let fromEntryY = this.#yCoordinateForNotVisibleEntry(entryFrom); // If the entry is visible, draw the arrow to the entry. if (entryFromVisibility) { const fromEntryParams = this.#positionEntryBorderOutlineType(entriesToConnect.entryFrom, entryFromWrapper); if (fromEntryParams) { const fromEntryHeight = fromEntryParams?.entryHeight; const fromEntryWidth = fromEntryParams?.entryWidth; const fromCutOffHeight = fromEntryParams?.cutOffHeight; fromEntryX = fromEntryParams?.x; fromEntryY = fromEntryParams?.y; component.fromEntryCoordinateAndDimensions = {x: fromEntryX, y: fromEntryY, length: fromEntryWidth, height: fromEntryHeight - fromCutOffHeight}; } else { // Something went if the entry is visible and we cannot get its' parameters. return; } } // If `fromEntry` is not visible and the link creation is not started yet, meaning that // only the button to create the link is displayed, delete the whole overlay. if (!entryFromVisibility && overlay.state === Trace.Types.File.EntriesLinkState.CREATION_NOT_STARTED) { this.dispatchEvent(new AnnotationOverlayActionEvent(overlay, 'Remove')); } // If entryTo exists, pass the coordinates and dimensions of the entry that the arrow snaps to. // If it does not, the event tracking mouse coordinates updates 'to coordinates' so the arrow follows the mouse instead. const entryToWrapper = component.entryToWrapper(); if (entryTo && entryToWrapper) { let toEntryX = this.xPixelForEventStartOnChart(entryTo) ?? 0; // If the 'to' entry is visible, set the entry Y as an arrow coordinate to point to. If not, get the canvas edge coordate to point the arrow to. let toEntryY = this.#yCoordinateForNotVisibleEntry(entryTo); const toEntryParams = this.#positionEntryBorderOutlineType(entryTo, entryToWrapper); if (toEntryParams) { const toEntryHeight = toEntryParams?.entryHeight; const toEntryWidth = toEntryParams?.entryWidth; const toCutOffHeight = toEntryParams?.cutOffHeight; toEntryX = toEntryParams?.x; toEntryY = toEntryParams?.y; component.toEntryCoordinateAndDimensions = { x: toEntryX, y: toEntryY, length: toEntryWidth, height: toEntryHeight - toCutOffHeight, }; } else { // if the entry exists and we cannot get its' parameters, it is probably loaded and is off screen. // In this case, assign the coordinates so we can draw the arrow in the right direction. component.toEntryCoordinateAndDimensions = { x: toEntryX, y: toEntryY, }; return; } } else { // If the 'to' entry does not exist, the link is being created. // The second coordinate for in progress link gets updated on mousemove this.#entriesLinkInProgress = overlay; } component.fromEntryIsSource = entryFromIsSource; component.toEntryIsSource = entryToIsSource; component.entriesVisibility = { fromEntryVisibility: entryFromVisibility, toEntryVisibility: entryToVisibility, }; } } /** * Return Y coordinate for an arrow connecting 2 entries to attach to if the entry is not visible. * For example, if the entry is scrolled up from the visible area , return the y index of the edge of the track: * -- * | | - entry off the visible chart * -- * * --Y--------------- -- Y is the returned coordinate that the arrow should point to * * flamechart data -- visible flamechart data between the 2 lines * ------------------ * * On the contrary, if the entry is scrolled off the bottom, get the coordinate of the top of the visible canvas. */ #yCoordinateForNotVisibleEntry(entry: Trace.Types.Overlays.OverlayEntry): number { const chartName = chartForEntry(entry); const y = this.yPixelForEventOnChart(entry); if (y === null) { return 0; } if (chartName === 'main') { if (!this.#dimensions.charts.main?.heightPixels) { // Shouldn't happen, but if the main chart has no height, nothing on it is visible. return 0; } const yWithoutNetwork = y - this.networkChartOffsetHeight(); // Check if the y position is less than 0. If it, the entry is off the top of the track canvas. // In that case, return the height of network track, which is also the top of main track. if (yWithoutNetwork < 0) { return this.networkChartOffsetHeight(); } } if (chartName === 'network') { if (!this.#dimensions.charts.network) { return 0; } // The event is off the bottom of the network chart. In this case return the bottom of the network chart. if (y > this.#dimensions.charts.network.heightPixels) { return this.#dimensions.charts.network.heightPixels; } } // In other cases, return the y of the entry return y; } #positionTimeRangeOverlay(overlay: Trace.Types.Overlays.TimeRangeLabel, element: HTMLElement): void { // Time ranges span both charts, it doesn't matter which one we pass here. // It's used to get the width of the container, and both charts have the // same width. const leftEdgePixel = this.#xPixelForMicroSeconds('main', overlay.bounds.min); const rightEdgePixel = this.#xPixelForMicroSeconds('main', overlay.bounds.max); if (leftEdgePixel === null || rightEdgePixel === null) { return; } const rangeWidth = rightEdgePixel - leftEdgePixel; element.style.left = `${leftEdgePixel}px`; element.style.width = `${rangeWidth}px`; } /** * @param overlay the EntrySelected overlay that we need to position. * @param element the DOM element representing the overlay */ #positionEntryLabelOverlay(overlay: Trace.Types.Overlays.EntryLabel, element: HTMLElement): number|null { // Because the entry outline is a common Overlay pattern, get the wrapper of the entry // that comes with the Trace.Types.Overlays.EntryLabel Overlay and pass it into the `positionEntryBorderOutlineType` // to draw and position it. The other parts of Trace.Types.Overlays.EntryLabel are drawn by the `EntryLabelOverlay` class. const component = element.querySelector('devtools-entry-label-overlay'); if (!component) { return null; } const entryWrapper = component.entryHighlightWrapper(); const inputField = component.shadowRoot?.querySelector('.input-field'); if (!entryWrapper) { return null; } const {entryHeight, entryWidth, cutOffHeight = 0, x, y} = this.#positionEntryBorderOutlineType(overlay.entry, entryWrapper) || {}; if (!entryHeight || !entryWidth || x === null || !y) { return null; } // Use the actual inputfield height to position the overlay, with a default value in case the element has not yet been rendered. const inputFieldHeight = inputField?.offsetHeight ?? 25; // Position the start of label overlay at the start of the entry + length of connector + length of the label element element.style.top = `${y - Components.EntryLabelOverlay.EntryLabelOverlay.LABEL_CONNECTOR_HEIGHT - inputFieldHeight}px`; element.style.left = `${x}px`; element.style.width = `${entryWidth}px`; return entryHeight - cutOffHeight; } #positionCandyStripedTimeRange(overlay: Trace.Types.Overlays.CandyStripedTimeRange, element: HTMLElement): void { const chartName = chartForEntry(overlay.entry); const startX = this.#xPixelForMicroSeconds(chartName, overlay.bounds.min); const endX = this.#xPixelForMicroSeconds(chartName, overlay.bounds.max); if (startX === null || endX === null) { return; } const widthPixels = endX - startX; // The entry selected overlay is always at least 2px wide. const finalWidth = Math.max(2, widthPixels); element.style.width = `${finalWidth}px`; element.style.left = `${startX}px`; let y = this.yPixelForEventOnChart(overlay.entry); if (y === null) { return; } const totalHeight = this.pixelHeightForEventOnChart(overlay.entry) ?? 0; // We might modify the height we use when drawing the overlay, hence copying the totalHeight. let height = totalHeight; if (height === null) { return; } // If the event is on the main chart, we need to adjust its selected border // if the event is cut off the top of the screen, because we need to ensure // that it does not overlap the resize element. Unfortunately we cannot // z-index our way out of this, so instead we calculate if the event is cut // off, and if it is, we draw the partial selected outline and do not draw // the top border, making it appear like it is going behind the resizer. // We don't need to worry about it going off the bottom, because in that // case we don't draw the overlay anyway. if (chartName === 'main') { const chartTopPadding = this.networkChartOffsetHeight(); // We now calculate the available height: if the entry is cut off we don't // show the border for the part that is cut off. const cutOffTop = y < chartTopPadding; height = cutOffTop ? Math.abs(y + height - chartTopPadding) : height; element.classList.toggle('cut-off-top', cutOffTop); if (cutOffTop) { // Adjust the y position: we need to move it down from the top Y // position to the Y position of the first visible pixel. The // adjustment is totalHeight - height because if the totalHeight is 17, // and the visibleHeight is 5, we need to draw the overlay at 17-5=12px // vertically from the top of the event. y = y + totalHeight - height; } } else { // If the event is on the network chart, we use the same logic as above // for the main chart, but to check if the event is cut off the bottom of // the network track and only part of the overlay is visible. // We don't need to worry about the event going off the top of the panel // as we can show the full overlay and it gets cut off by the minimap UI. const networkHeight = this.#dimensions.charts.network?.heightPixels ?? 0; const lastVisibleY = y + totalHeight; const cutOffBottom = lastVisibleY > networkHeight; const cutOffTop = y > networkHeight; element.classList.toggle('cut-off-top', cutOffTop); element.classList.toggle('cut-off-bottom', cutOffBottom); if (cutOffBottom) { // Adjust the height of the overlay to be the amount of visible pixels. height = networkHeight - y; } } element.style.height = `${height}px`; element.style.top = `${y}px`; } /** * Draw and position borders around an entry. Multiple overlays either fully consist * of a border around an entry of have an entry border as a part of the overlay. * Positions an EntrySelected or EntryOutline overlay and a part of the Trace.Types.Overlays.EntryLabel. * @param overlay the EntrySelected/EntryOutline/Trace.Types.Overlays.EntryLabel overlay that we need to position. * @param element the DOM element representing the overlay */ #positionEntryBorderOutlineType(entry: Trace.Types.Overlays.OverlayEntry, element: HTMLElement): {entryHeight: number, entryWidth: number, cutOffHeight: number, x: number, y: number}|null { const chartName = chartForEntry(entry); let x = this.xPixelForEventStartOnChart(entry); let y = this.yPixelForEventOnChart(entry); const chartWidth = (chartName === 'main') ? this.#dimensions.charts.main?.widthPixels : this.#dimensions.charts.network?.widthPixels; if (x === null || y === null || !chartWidth) { return null; } const {endTime} = timingsForOverlayEntry(entry); const endX = this.#xPixelForMicroSeconds(chartName, endTime); if (endX === null) { return null; } const totalHeight = this.pixelHeightForEventOnChart(entry) ?? 0; // We might modify the height we use when drawing the overlay, hence copying the totalHeight. let height = totalHeight; if (height === null) { return null; } // The width of the overlay is by default the width of the entry. However // we modify that for instant events like LCP markers, and also ensure a // minimum width. let widthPixels = endX - x; const provider = chartName === 'main' ? this.#charts.mainProvider : this.#charts.networkProvider; const chart = chartName === 'main' ? this.#charts.mainChart : this.#charts.networkChart; const index = provider.indexForEvent?.(entry); const customPos = chart.getCustomDrawnPositionForEntryIndex(index ?? -1); if (customPos) { // Some events like markers and layout shifts define their exact coordinates explicitly. // If this is one of those events we should change the overlay coordinates to match. x = customPos.x; widthPixels = customPos.width; } // Calculate the visible overlay width by subtracting the entry width that is outside of the flamechart width const cutOffRight = (x + widthPixels > chartWidth) ? (x + widthPixels) - chartWidth : null; const cutOffLeft = (x < 0) ? Math.abs(x) : null; element.classList.toggle('cut-off-right', cutOffRight !== null); if (cutOffRight) { widthPixels = widthPixels - cutOffRight; } if (cutOffLeft) { // If the entry is cut off from the left, move its beginning to the left most part of the flamechart x = 0; widthPixels = widthPixels - cutOffLeft; } // The entry selected overlay is always at least 2px wide. const finalWidth = Math.max(2, widthPixels); element.style.width = `${finalWidth}px`; // If the event is on the main chart, we need to adjust its selected border // if the event is cut off the top of the screen, because we need to ensure // that it does not overlap the resize element. Unfortunately we cannot // z-index our way out of this, so instead we calculate if the event is cut // off, and if it is, we draw the partial selected outline and do not draw // the top border, making it appear like it is going behind the resizer. // We don't need to worry about it going off the bottom, because in that // case we don't draw the overlay anyway. if (chartName === 'main') { const chartTopPadding = this.networkChartOffsetHeight(); // We now calculate the available height: if the entry is cut off we don't // show the border for the part that is cut off. const cutOffTop = y < chartTopPadding; height = cutOffTop ? Math.abs(y + height - chartTopPadding) : height; element.classList.toggle('cut-off-top', cutOffTop); if (cutOffTop) { // Adjust the y position: we need to move it down from the top Y // position to the Y position of the first visible pixel. The // adjustment is totalHeight - height because if the totalHeight is 17, // and the visibleHeight is 5, we need to draw the overlay at 17-5=12px // vertically from the top of the event. y = y + totalHeight - height; } } else { // If the event is on the network chart, we use the same logic as above // for the main chart, but to check if the event is cut off the bottom of // the network track and only part of the overlay is visible. // We don't need to worry about the even going off the top of the panel // as we can show the full overlay and it gets cut off by the minimap UI. const networkHeight = this.#dimensions.charts.network?.heightPixels ?? 0; const lastVisibleY = y + totalHeight; const cutOffBottom = lastVisibleY > networkHeight; element.classList.toggle('cut-off-bottom', cutOffBottom); if (cutOffBottom) { // Adjust the height of the overlay to be the amount of visible pixels. height = networkHeight - y; } } element.style.height = `${height}px`; element.style.top = `${y}px`; element.style.left = `${x}px`; return {entryHeight: totalHeight, entryWidth: finalWidth, cutOffHeight: totalHeight - height, x, y}; } /** * We draw an arrow between connected entries but this can get complicated * depending on if the entries are visible or not. For example, the user might * draw a connection to an entry in the main thread but then collapse the * parent of that entry. In this case the entry we want to draw to is the * first visible parent of that entry rather than the (invisible) entry. */ #calculateFromAndToForEntriesLink(overlay: Trace.Types.Overlays.EntriesLink): EntriesLinkVisibleEntries|null { if (!overlay.entryTo) { // This case is where the user has clicked on the first entry and needs // to pick a second. In this case they can only pick from visible // entries, so we don't need to do any checks and can just return. return { entryFrom: overlay.entryFrom, entryTo: overlay.entryTo, entryFromIsSource: true, entryToIsSource: true, }; } let entryFrom: Trace.Types.Overlays.OverlayEntry|null = overlay.entryFrom; let entryTo: Trace.Types.Overlays.OverlayEntry|null = overlay.entryTo ?? null; if (this.#queries.isEntryCollapsedByUser(overlay.entryFrom)) { entryFrom = this.#queries.firstVisibleParentForEntry(overlay.entryFrom); } if (overlay.entryTo && this.#queries.isEntryCollapsedByUser(overlay.entryTo)) { entryTo = this.#queries.firstVisibleParentForEntry(overlay.entryTo); } if (entryFrom === null || entryTo === null) { // We cannot draw this overlay; so return null; // The only valid case of entryTo being null/undefined has been dealt // with already at the start of this function. return null; } return { entryFrom, entryFromIsSource: entryFrom === overlay.entryFrom, entryTo, entryToIsSource: entryTo === overlay.entryTo, }; } // Dimms all label annotations except the one that is hovered over in the timeline or sidebar. // The highlighter annotation is brought forward. highlightOverlay(overlay: Trace.Types.Overlays.EntryLabel): void { const allLabelOverlays = this.overlaysOfType('ENTRY_LABEL'); for (const otherOverlay of allLabelOverlays) { const element = this.elementForOverlay(otherOverlay); const component = element?.querySelector('devtools-entry-label-overlay'); if (element && !component?.hasAttribute('data-user-editing-label')) { if (otherOverlay === overlay) { element.style.opacity = '1'; element.style.zIndex = '3'; } else { element.style.opacity = '0.5'; element.style.zIndex = '2'; } } } } undimAllEntryLabels(): void { const allLabelOverlays = this.overlaysOfType('ENTRY_LABEL'); for (const otherOverlay of allLabelOverlays) { const element = this.elementForOverlay(otherOverlay); if (element) { element.style.opacity = '1'; element.style.zIndex = '2'; } } } #createElementForNewOverlay(overlay: Trace.Types.Overlays.Overlay): HTMLElement { const overlayElement = document.createElement('div'); overlayElement.classList.add('overlay-item', `overlay-type-${overlay.type}`); const jslogContext = jsLogContext(overlay); if (jslogContext) { overlayElement.setAttribute('jslog', `${VisualLogging.item(jslogContext)}`); } switch (overlay.type) { case 'ENTRY_LABEL': { const shouldDrawLabelBelowEntry = Trace.Types.Events.isLegacyTimelineFrame(overlay.entry); const component = new Components.EntryLabelOverlay.EntryLabelOverlay(overlay.label, shouldDrawLabelBelowEntry); // Generate the AI Call Tree for the AI Auto-Annotation feature. const parsedTrace = this.#queries.parsedTrace(); const callTree = parsedTrace ? AIAssistance.AICallTree.AICallTree.fromEvent(overlay.entry, parsedTrace) : null; component.callTree = callTree; component.addEventListener( Components.EntryLabelOverlay.LabelAnnotationsConsentDialogVisibilityChange.eventName, e => { const event = e as Components.EntryLabelOverlay.LabelAnnotationsConsentDialogVisibilityChange; this.dispatchEvent(new ConsentDialogVisibilityChange(event.isVisible)); }); component.addEventListener(Components.EntryLabelOverlay.EntryLabelRemoveEvent.eventName, () => { this.dispatchEvent(new AnnotationOverlayActionEvent(overlay, 'Remove')); }); component.addEventListener(Components.EntryLabelOverlay.EntryLabelChangeEvent.eventName, event => { const newLabel = (event as Components.EntryLabelOverlay.EntryLabelChangeEvent).newLabel; overlay.label = newLabel; this.dispatchEvent(new AnnotationOverlayActionEvent(overlay, 'Update')); }); overlayElement.addEventListener('mouseover', () => { this.highlightOverlay(overlay); }); overlayElement.addEventListener('mouseout', () => { this.undimAllEntryLabels(); }); overlayElement.appendChild(component); overlayElement.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); this.dispatchEvent(new EntryLabelMouseClick(overlay)); }); return overlayElement; } case 'ENTRIES_LINK': { const entries = this.#calculateFromAndToForEntriesLink(overlay); if (entries === null) { // For some reason, we don't have two entries we can draw between // (can happen if the user has collapsed an icicle in the flame // chart, or a track), so just draw an empty div. return overlayElement; } const entryEndX = this.xPixelForEventEndOnChart(entries.entryFrom) ?? 0; const entryStartX = this.xPixelForEventEndOnChart(entries.entryFrom) ?? 0; const entryStartY = (this.yPixelForEventOnChart(entries.entryFrom) ?? 0); const entryWidth = entryEndX - entryStartX; const entryHeight = this.pixelHeightForEventOnChart(entries.entryFrom) ?? 0; const component = new Components.EntriesLinkOverlay.EntriesLinkOverlay( {x: entryEndX, y: entryStartY, width: entryWidth, height: entryHeight}, overlay.state); component.addEventListener(Components.EntriesLinkOverlay.EntryLinkStartCreating.eventName, () => { overlay.state = Trace.Types.File.EntriesLinkState.PENDING_TO_EVENT; this.dispatchEvent(new AnnotationOverlayActionEvent(overlay, 'Update')); }); overlayElement.appendChild(component); return overlayElement; } case 'ENTRY_OUTLINE': { overlayElement.classList.add(`outline-reason-${overlay.outlineReason}`); return overlayElement; } case 'TIME_RANGE': { const component = new Components.TimeRangeOverlay.TimeRangeOverlay(overlay.label); component.duration = overlay.showDuration ? overlay.bounds.range : null; component.canvasRect = this.#charts.mainChart.canvasBoundingClientRect(); component.addEventListener(Components.TimeRangeOverlay.TimeRangeLabelChangeEvent.eventName, event => { const newLabel = (event as Components.TimeRangeOverlay.TimeRangeLabelChangeEvent).newLabel; overlay.label = newLabel; this.dispatchEvent(new AnnotationOverlayActionEvent(overlay, 'Update')); }); component.addEventListener(Components.TimeRangeOverlay.TimeRangeRemoveEvent.eventName, () => { this.dispatchEvent(new AnnotationOverlayActionEvent(overlay, 'Remove')); }); component.addEventListener('mouseover', () => { this.dispatchEvent(new TimeRangeMouseOverEvent(overlay)); }); component.addEventListener('mouseout', () => { this.dispatchEvent(new TimeRangeMouseOutEvent()); }); overlayElement.appendChild(component); return overlayElement; } case 'TIMESPAN_BREAKDOWN': { const widget = document.createElement('devtools-widget') as UI.Widget.WidgetElement; widget.widgetConfig = UI.Widget.widgetConfig(Components.TimespanBreakdownOverlay.TimespanBreakdownOverlay, { isBelowEntry: overlay.renderLocation === 'BELOW_EVENT', canvasRect: this.#charts.mainChart.canvasBoundingClientRect(), sections: overlay.sections, }); overlayElement.appendChild(widget); return overlayElement; } case 'TIMINGS_MARKER': { const {color} = Trace.Styles.markerDetailsForEvent(overlay.entries[0]); const markersComponent = this.#createTimingsMarkerElement(overlay); overlayElement.appendChild(markersComponent); overlayElement.style.setProperty('--marker-color', color); return overlayElement; } default: { return overlayElement; } } } #clickEvent(event: Trace.Types.Events.Event): void { this.dispatchEvent(new EventReferenceClick(event)); } #createOverlayPopover( adjustedTimestamp: Trace.Types.Timing.Micro, name: string, fieldResult: Trace.Types.Overlays.TimingsMarkerFieldResult|undefined): HTMLElement { const popoverElement = document.createElement('div'); const popoverContents = popoverElement.createChild('div', 'overlay-popover'); popoverContents.createChild('span', 'overlay-popover-time').textContent = i18n.TimeUtilities.formatMicroSecondsTime(adjustedTimestamp); popoverContents.createChild('span', 'overlay-popover-title').textContent = fieldResult ? i18nString(UIStrings.fieldMetricMarkerLocal, {PH1: name}) : name; // If there's field data, make another row. if (fieldResult) { const popoverContents = popoverElement.createChild('div', 'overlay-popover'); popoverContents.createChild('span', 'overlay-popover-time').textContent = i18n.TimeUtilities.formatMicroSecondsTime(fieldResult.value); let scope: string = fieldResult.pageScope; if (fieldResult.pageScope === 'url') { scope = i18nString(UIStrings.urlOption); } else if (fieldResult.pageScope === 'origin') { scope = i18nString(UIStrings.originOption); } popoverContents.createChild('span', 'overlay-popover-title').textContent = i18nString(UIStrings.fieldMetricMarkerField, { PH1: name, PH2: scope, }); } return popoverElement; } #mouseMoveOverlay( e: MouseEvent, event: Trace.Types.Events.PageLoadEvent, name: string, overlay: Trace.Types.Overlays.TimingsMarker, markers: HTMLElement, marker: HTMLElement): void { if (Trace.Types.Events.isSoftNavigationStart(event)) { name = 'Soft Nav'; } else if (Trace.Types.Events.isSoftLargestContentfulPaintCandidate(event)) { name = 'Soft LCP'; } const fieldResult = overlay.entryToFieldResult.get(event); const popoverElement = this.#createOverlayPopover(overlay.adjustedTimestamp, name, fieldResult); this.#lastMouseOffsetX = e.offsetX + (markers.offsetLeft || 0) + (marker.offsetLeft || 0); this.#lastMouseOffsetY = e.offsetY + markers.offsetTop || 0; this.#charts.mainChart.updateMouseOffset(this.#lastMouseOffsetX, this.#lastMouseOffsetY); this.#charts.mainChart.updatePopoverContents(popoverElement); } #mouseOutOverlay(): void { this.#lastMouseOffsetX = -1; this.#lastMouseOffsetY = -1; this.#charts.mainChart.updateMouseOffset(this.#lastMouseOffsetX, this.#lastMouseOffsetY); this.#charts.mainChart.hideHighlight(); } #createTimingsMarkerElement(overlay: Trace.Types.Overlays.TimingsMarker): HTMLElement { const markers = document.createElement('div'); markers.classList.add('markers'); for (const entry of overlay.entries) { const {color, title} = Trace.Styles.markerDetailsForEvent(entry); const marker = document.createElement('div'); marker.classList.add('marker-title'); marker.textContent = title; marker.style.backgroundColor = color; markers.appendChild(marker); marker.addEventListener('click', () => this.#clickEvent(entry)); // Popover. marker.addEventListener('mousemove', e => this.#mouseMoveOverlay(e, entry, title, overlay, markers, marker)); marker.addEventListener('mouseout', () => this.#mouseOutOverlay()); } return markers; } /** * Some overlays store data in their components that needs to be updated * before we position an overlay. Else, we might position an overlay based on * stale data. This method is used to update an overlay BEFORE it is then * positioned onto the canvas. It is the right place to ensure an overlay has * the latest data it needs. */ #updateOverlayBeforePositioning(overlay: Trace.Types.Overlays.Overlay, element: HTMLElement): void { switch (overlay.type) { case 'ENTRY_SELECTED': break; case 'TIME_RANGE': { const component = element.querySelector('devtools-time-range-overlay'); if (component) { component.duration = overlay.showDuration ? overlay.bounds.range : null; component.canvasRect = this.#charts.mainChart.canvasBoundingClientRect(); } break; } case 'ENTRY_LABEL': case 'ENTRY_OUTLINE': case 'ENTRIES_LINK': { const component = element.querySelector('devtools-entries-link-overlay'); if (component) { component.canvasRect = this.#charts.mainChart.canvasBoundingClientRect(); } break; } case 'TIMESPAN_BREAKDOWN': { const component = element.querySelector('.devtools-timespan-breakdown-overlay'); if (!component) { return; } const widget = UI.Widget.Widget.get(component) as Components.TimespanBreakdownOverlay.TimespanBreakdownOverlay; if (widget) { widget.sections = overlay.sections; widget.canvasRect = this.#charts.mainChart.canvasBoundingClientRect(); } break; } case 'TIMESTAMP_MARKER': break; case 'CANDY_STRIPED_TIME_RANGE': break; case 'TIMINGS_MARKER': break; case 'BOTTOM_INFO_BAR': { if (element.contains(overlay.infobar.element)) { return; } // This overlay is a singleton; this means it could be updated with a // different info bar. So we need to clear out the existing contents // before appending the infobar, just in case. element.innerHTML = ''; element.appendChild(overlay.infobar.element); } break; default: Platform.TypeScriptUtilities.assertNever(overlay, `Unexpected overlay ${overlay}`); } } /** * Some overlays have custom logic within them to manage visibility of * labels/etc that can be impacted if the positioning or size of the overlay * has changed. This method can be used to run code after an overlay has * been updated + repositioned on the timeline. */ #updateOverlayAfterPositioning(overlay: Trace.Types.Overlays.Overlay, element: HTMLElement): void { switch (overlay.type) { case 'ENTRY_SELECTED': break; case 'TIME_RANGE': { const component = element.querySelector('devtools-time-range-overlay'); component?.updateLabelPositioning(); break; } case 'ENTRY_LABEL': break; case 'ENTRY_OUTLINE': break; case 'ENTRIES_LINK': break; case 'TIMESPAN_BREAKDOWN': { const component = element.querySelector('.devtools-timespan-breakdown-overlay'); if (!component) { return; } const widget = UI.Widget.Widget.get(component) as Components.TimespanBreakdownOverlay.TimespanBreakdownOverlay; widget?.checkSectionLabelPositioning(); break; } case 'TIMESTAMP_MARKER': break; case 'CANDY_STRIPED_TIME_RANGE': break; case 'TIMINGS_MARKER': break; case 'BOTTOM_INFO_BAR': break; default: Platform.TypeScriptUtilities.assertNever(overlay, `Unexpected overlay ${overlay}`); } } /** * @returns true if the entry is visible on chart, which means that both * horizontally and vertically it is at least partially in view. */ entryIsVisibleOnChart(entry: Trace.Types.Overlays.OverlayEntry): boolean { const verticallyVisible = this.#entryIsVerticallyVisibleOnChart(entry); const horiziontallyVisible = this.#entryIsHorizontallyVisibleOnChart(entry); return verticallyVisible && horiziontallyVisible; } /** * Calculates if an entry is visible horizontally. This is easy because we * don't have to consider any pixels and can instead check that its start and * end times intersect with the visible window. */ #entryIsHorizontallyVisibleOnChart(entry: Trace.Types.Overlays.OverlayEntry): boolean { if (this.#dimensions.trace.visibleWindow === null) { return false; } const {startTime, endTime} = timingsForOverlayEntry(entry); const entryTimeRange = Trace.Helpers.Timing.traceWindowFromMicroSeconds(startTime, endTime); return Trace.Helpers.Timing.boundsIncludeTimeRange({ bounds: this.#dimensions.trace.visibleWindow, timeRange: entryTimeRange, }); } #entryIsInCollapsedTrack(entry: Trace.Types.Overlays.OverlayEntry): boolean { const chartName = chartForEntry(entry); const provider = chartName === 'main' ? this.#charts.mainProvider : this.#charts.networkProvider; const entryIndex = provider.indexForEvent?.(entry) ?? null; if (entryIndex === null) { return false; } const group = provider.groupForEvent?.(entryIndex) ?? null; if (!group) { return false; } return Boolean(group.expanded) === false; } /** * Calculate if an entry is visible vertically on the chart. A bit fiddly as * we have to figure out its pixel offset and go on that. Unlike horizontal * visibility, we can't work solely from its microsecond values. */ #entryIsVerticallyVisibleOnChart(entry: Trace.Types.Overlays.OverlayEntry): boolean { const chartName = chartForEntry(entry); const y = this.yPixelForEventOnChart(entry); if (y === null) { return false; } const eventHeight = this.pixelHeightForEventOnChart(entry); if (!eventHeight) { return false; } if (chartName === 'main') { if (!this.#dimensions.charts.main?.heightPixels) { // Shouldn't happen, but if the main chart has no height, nothing on it is visible. return false; } // The yPixelForEventOnChart method returns the y pixel including an adjustment for the network track. // To see if an entry on the main flame chart is visible, we can check // its y value without the network track adjustment. If it is < 0, then // it's off the top of the screen. // const yWithoutNetwork = y - this.networkChartOffsetHeight(); // Check if the y position + the height is less than 0. We add height so // that we correctly consider an event only partially scrolled off to be // visible. if (yWithoutNetwork + eventHeight < 0) { return false; } if (yWithoutNetwork > this.#dimensions.charts.main.heightPixels) { // The event is off the bottom of the screen. return false; } } if (chartName === 'network') { if (!this.#dimensions.charts.network) { // The network chart can be hidden if there are no requests in the trace. return false; } if (y <= -14) { // Weird value, but the network chart has the header row with // timestamps on it: events stay visible behind those timestamps, so we // want any overlays to treat themselves as visible too. return false; } if (y > this.#dimensions.charts.network.heightPixels) { // The event is off the bottom of the network chart. return false; } } // If we got here, none of the conditions to mark an event as invisible got // triggered, so the event must be visible. return true; } /** * Calculate the X pixel position for an event start on the timeline. * @param chartName the chart that the event is on. It is expected that both * charts have the same width so this doesn't make a difference - but it might * in the future if the UI changes, hence asking for it. * @param event the trace event you want to get the pixel position of */ xPixelForEventStartOnChart(event: Trace.Types.Overlays.OverlayEntry): number|null { const chartName = chartForEntry(event); const {startTime} = timingsForOverlayEntry(event); return this.#xPixelForMicroSeconds(chartName, startTime); } /** * Calculate the X pixel position for an event end on the timeline. * @param chartName the chart that the event is on. It is expected that both * charts have the same width so this doesn't make a difference - but it might * in the future if the UI changes, hence asking for it. * @param event the trace event you want to get the pixel position of */ xPixelForEventEndOnChart(event: Trace.Types.Overlays.OverlayEntry): number|null { const chartName = chartForEntry(event); const {endTime} = timingsForOverlayEntry(event); return this.#xPixelForMicroSeconds(chartName, endTime); } /** * Calculate the xPixel for a given timestamp. To do this we calculate how * far in microseconds from the left of the visible window an event is, and * divide that by the total time span. This gives us a fraction representing * how far along the timeline the event is. We can then multiply that by the * width of the canvas to get its pixel position. */ #xPixelForMicroSeconds(chart: EntryChartLocation, timestamp: Trace.Types.Timing.Micro): number|null { if (this.#dimensions.trace.visibleWindow === null) { console.error('Cannot calculate xPixel without visible trace window.'); return null; } const canvasWidthPixels = this.#dimensions.charts[chart]?.widthPixels ?? null; if (canvasWidthPixels === null) { console.error(`Cannot calculate xPixel without ${chart} dimensions.`); return null; } const timeFromLeft = timestamp - this.#dimensions.trace.visibleWindow.min; const totalTimeSpan = this.#dimensions.trace.visibleWindow.range; return Math.floor( timeFromLeft / totalTimeSpan * canvasWidthPixels, ); } /** * Calculate the Y pixel position for the event on the timeline relative to * the entire window. * This means if the event is in the main flame chart and below the network, * we add the height of the network chart to the Y value to position it * correctly. * This can return null if any data was missing, or if the event is not * visible (if the level it's on is hidden because the track is collapsed, * for example) */ yPixelForEventOnChart(event: Trace.Types.Overlays.OverlayEntry): number|null { const chartName = chartForEntry(event); const chart = chartName === 'main' ? this.#charts.mainChart : this.#charts.networkChart; const provider = chartName === 'main' ? this.#charts.mainProvider : this.#charts.networkProvider; const indexForEntry = provider.indexForEvent?.(event); if (typeof indexForEntry !== 'number') { return null; } const timelineData = provider.timelineData(); if (timelineData === null) { return null; } const level = timelineData.entryLevels.at(indexForEntry); if (typeof level === 'undefined') { return null; } if (!chart.levelIsVisible(level)) { return null; } const pixelOffsetForLevel = chart.levelToOffset(level); // Now we have the offset for the level, we need to adjust it by the user's scroll offset. let pixelAdjustedForScroll = pixelOffsetForLevel - (this.#dimensions.charts[chartName]?.scrollOffsetPixels ?? 0); // Now if the event is in the main chart, we need to pad its Y position // down by the height of the network chart + the network resize element. if (chartName === 'main') { pixelAdjustedForScroll += this.networkChartOffsetHeight(); } return pixelAdjustedForScroll; } /** * Calculate the height of the event on the timeline. */ pixelHeightForEventOnChart(event: Trace.Types.Overlays.OverlayEntry): number|null { const chartName = chartForEntry(event); const chart = chartName === 'main' ? this.#charts.mainChart : this.#charts.networkChart; const provider = chartName === 'main' ? this.#charts.mainProvider : this.#charts.networkProvider; const indexForEntry = provider.indexForEvent?.(event); if (typeof indexForEntry !== 'number') { return null; } const timelineData = provider.timelineData(); if (timelineData === null) { return null; } const level = timelineData.entryLevels.at(indexForEntry); if (typeof level === 'undefined') { return null; } return chart.levelHeight(level); } /** * Calculate the height of the network chart. If the network chart has * height, we also allow for the size of the resize handle shown between the * two charts. * * Note that it is possible for the chart to have 0 height if the user is * looking at a trace with no network requests. */ networkChartOffsetHeight(): number { if (this.#dimensions.charts.network === null) { return 0; } if (this.#dimensions.charts.network.heightPixels === 0) { return 0; } // At this point we know the network track exists and has height. But we // need to check if it is collapsed, because if it is collapsed there is no // resizer shown. if (this.#dimensions.charts.network.allGroupsCollapsed) { return this.#dimensions.charts.network.heightPixels; } return this.#dimensions.charts.network.heightPixels + NETWORK_RESIZE_ELEM_HEIGHT_PX; } /** * Hides or shows an element. We used to use visibility rather than display, * but a child of an element with visibility: hidden may still be visible if * its own `display` property is set. */ #setOverlayElementVisibility(element: HTMLElement, isVisible: boolean): void { element.style.display = isVisible ? 'block' : 'none'; } } /** * Because entries can be a TimelineFrame, which is not a trace event, this * helper exists to return a consistent set of timings regardless of the type * of entry. */ export function timingsForOverlayEntry(entry: Trace.Types.Overlays.OverlayEntry): Trace.Helpers.Timing.EventTimingsData { if (Trace.Types.Events.isLegacyTimelineFrame(entry)) { return { startTime: entry.startTime, endTime: entry.endTime, duration: entry.duration, }; } return Trace.Helpers.Timing.eventTimingsMicroSeconds(entry); } /** * Defines if the overlay container `div` should have a jslog context attached. * Note that despite some of the overlays being used currently exclusively * for annotations, we log here with `overlays` to be generic as overlays can * be used for insights, annotations or in the future, who knows... */ export function jsLogContext(overlay: Trace.Types.Overlays.Overlay): string|null { switch (overlay.type) { case 'ENTRY_SELECTED': { // No jslog for this; it would be very noisy and not very useful. return null; } case 'ENTRY_OUTLINE': { return `timeline.overlays.entry-outline-${Platform.StringUtilities.toKebabCase(overlay.outlineReason)}`; } case 'ENTRY_LABEL': { return 'timeline.overlays.entry-label'; } case 'ENTRIES_LINK': { // do not log impressions for incomplete entry links if (overlay.state !== Trace.Types.File.EntriesLinkState.CONNECTED) { return null; } return 'timeline.overlays.entries-link'; } case 'TIME_RANGE': { return 'timeline.overlays.time-range'; } case 'TIMESPAN_BREAKDOWN': { return 'timeline.overlays.timespan-breakdown'; } case 'TIMESTAMP_MARKER': { return 'timeline.overlays.cursor-timestamp-marker'; } case 'CANDY_STRIPED_TIME_RANGE': { return 'timeline.overlays.candy-striped-time-range'; } case 'TIMINGS_MARKER': { return 'timeline.overlays.timings-marker'; } case 'BOTTOM_INFO_BAR': return 'timeline.overlays.info-bar'; default: Platform.assertNever(overlay, 'Unknown overlay type'); } }