UNPKG

chrome-devtools-frontend

Version:
1,326 lines (1,169 loc) • 84.1 kB
// Copyright 2024 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. /* eslint-disable rulesdir/no-imperative-dom-api */ import * as Common from '../../../core/common/common.js'; import * as i18n from '../../../core/i18n/i18n.js'; import * as Platform from '../../../core/platform/platform.js'; import * as Trace from '../../../models/trace/trace.js'; import type * as PerfUI from '../../../ui/legacy/components/perf_ui/perf_ui.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import * as Utils from '../utils/utils.js'; import * as Components from './components/components.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'; /** * You can add overlays to trace events, but also right now frames are drawn on * the timeline but they are not trace events, so we need to allow for that. * In the future when the frames track has been migrated to be powered by * animation frames (crbug.com/345144583), we can remove the requirement to * support TimelineFrame instances (which themselves will be removed from the * codebase.) */ export type OverlayEntry = Trace.Types.Events.Event|Trace.Types.Events.LegacyTimelineFrame; /** * Represents when a user has selected an entry in the timeline */ export interface EntrySelected { type: 'ENTRY_SELECTED'; entry: OverlayEntry; } /** * Drawn around an entry when we want to highlight it to the user. */ export interface EntryOutline { type: 'ENTRY_OUTLINE'; entry: OverlayEntry; outlineReason: 'ERROR'|'INFO'; } /** * Represents an object created when a user creates a label for an entry in the timeline. */ export interface EntryLabel { type: 'ENTRY_LABEL'; entry: OverlayEntry; label: string; } export interface EntriesLink { type: 'ENTRIES_LINK'; state: Trace.Types.File.EntriesLinkState; entryFrom: OverlayEntry; entryTo?: OverlayEntry; } /** * Represents a time range on the trace. Also used when the user shift+clicks * and drags to create a time range. */ export interface TimeRangeLabel { type: 'TIME_RANGE'; bounds: Trace.Types.Timing.TraceWindowMicro; label: string; showDuration: boolean; } /** * 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: TimelineOverlay[]): Trace.Types.Timing.TraceWindowMicro|null { let minTime = Trace.Types.Timing.Micro(Number.POSITIVE_INFINITY); let maxTime = Trace.Types.Timing.Micro(Number.NEGATIVE_INFINITY); if (overlays.length === 0) { return null; } for (const overlay of overlays) { const windowForOverlay = traceWindowForOverlay(overlay); if (windowForOverlay.min < minTime) { minTime = windowForOverlay.min; } if (windowForOverlay.max > maxTime) { maxTime = windowForOverlay.max; } } return Trace.Helpers.Timing.traceWindowFromMicroSeconds(minTime, maxTime); } function traceWindowForOverlay(overlay: TimelineOverlay): Trace.Types.Timing.TraceWindowMicro { const overlayMinBounds: Trace.Types.Timing.Micro[] = []; const overlayMaxBounds: Trace.Types.Timing.Micro[] = []; switch (overlay.type) { case 'ENTRY_SELECTED': { const timings = timingsForOverlayEntry(overlay.entry); overlayMinBounds.push(timings.startTime); overlayMaxBounds.push(timings.endTime); break; } case 'ENTRY_OUTLINE': { const timings = timingsForOverlayEntry(overlay.entry); overlayMinBounds.push(timings.startTime); overlayMaxBounds.push(timings.endTime); break; } case 'TIME_RANGE': { overlayMinBounds.push(overlay.bounds.min); overlayMaxBounds.push(overlay.bounds.max); break; } case 'ENTRY_LABEL': { const timings = timingsForOverlayEntry(overlay.entry); overlayMinBounds.push(timings.startTime); overlayMaxBounds.push(timings.endTime); break; } case 'ENTRIES_LINK': { const timingsFrom = timingsForOverlayEntry(overlay.entryFrom); overlayMinBounds.push(timingsFrom.startTime); if (overlay.entryTo) { const timingsTo = timingsForOverlayEntry(overlay.entryTo); // No need to push the startTime; it must be larger than the entryFrom start time. overlayMaxBounds.push(timingsTo.endTime); } else { // Only use the end time if we have no entryTo; otherwise the entryTo // endTime is guaranteed to be larger than the entryFrom endTime. overlayMaxBounds.push(timingsFrom.endTime); } break; } case 'TIMESPAN_BREAKDOWN': { if (overlay.entry) { const timings = timingsForOverlayEntry(overlay.entry); overlayMinBounds.push(timings.startTime); overlayMaxBounds.push(timings.endTime); } for (const section of overlay.sections) { overlayMinBounds.push(section.bounds.min); overlayMaxBounds.push(section.bounds.max); } break; } case 'TIMESTAMP_MARKER': { overlayMinBounds.push(overlay.timestamp); break; } case 'CANDY_STRIPED_TIME_RANGE': { const timings = timingsForOverlayEntry(overlay.entry); overlayMinBounds.push(timings.startTime); overlayMaxBounds.push(timings.endTime); overlayMinBounds.push(overlay.bounds.min); overlayMaxBounds.push(overlay.bounds.max); break; } case 'TIMINGS_MARKER': { const timings = timingsForOverlayEntry(overlay.entries[0]); overlayMinBounds.push(timings.startTime); break; } default: Platform.TypeScriptUtilities.assertNever(overlay, `Unexpected overlay ${overlay}`); } const min = Trace.Types.Timing.Micro(Math.min(...overlayMinBounds)); const max = Trace.Types.Timing.Micro(Math.max(...overlayMaxBounds)); return Trace.Helpers.Timing.traceWindowFromMicroSeconds(min, max); } /** * Get a list of entries for a given overlay. */ export function entriesForOverlay(overlay: TimelineOverlay): readonly OverlayEntry[] { const entries: 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; } default: Platform.assertNever(overlay, `Unknown overlay type ${JSON.stringify(overlay)}`); } return entries; } export function chartForEntry(entry: OverlayEntry): EntryChartLocation { if (Trace.Types.Events.isNetworkTrackEntry(entry)) { return 'network'; } return 'main'; } /** * Used to highlight with a red-candy stripe a time range. It takes an entry * because this entry is the row that will be used to place the candy stripe, * and its height will be set to the height of that row. */ export interface CandyStripedTimeRange { type: 'CANDY_STRIPED_TIME_RANGE'; bounds: Trace.Types.Timing.TraceWindowMicro; entry: Trace.Types.Events.Event; } /** * Represents a timespan on a trace broken down into parts. Each part has a label to it. * If an entry is defined, the breakdown will be vertically positioned based on it. */ export interface TimespanBreakdown { type: 'TIMESPAN_BREAKDOWN'; sections: Components.TimespanBreakdownOverlay.EntryBreakdown[]; entry?: Trace.Types.Events.Event; renderLocation?: 'BOTTOM_OF_TIMELINE'|'BELOW_EVENT'|'ABOVE_EVENT'; } export interface TimestampMarker { type: 'TIMESTAMP_MARKER'; timestamp: Trace.Types.Timing.Micro; } /** * Represents a timings marker. This has a line that runs up the whole canvas. * We can hold an array of entries, in the case we want to hold more than one with the same timestamp. * The adjusted timestamp being the timestamp for the event adjusted by closest navigation. */ export interface TimingsMarker { type: 'TIMINGS_MARKER'; entries: Trace.Types.Events.PageLoadEvent[]; entryToFieldResult: Map<Trace.Types.Events.PageLoadEvent, TimingsMarkerFieldResult>; adjustedTimestamp: Trace.Types.Timing.Micro; } export type TimingsMarkerFieldResult = Trace.Insights.Common.CrUXFieldMetricTimingResult; /** * All supported overlay types. */ export type TimelineOverlay = EntrySelected|EntryOutline|TimeRangeLabel|EntryLabel|EntriesLink|TimespanBreakdown| TimestampMarker|CandyStripedTimeRange|TimingsMarker; 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 = EntrySelected|TimestampMarker; export function overlayIsSingleton(overlay: TimelineOverlay): overlay is SingletonOverlay { return overlayTypeIsSingleton(overlay.type); } export function overlayTypeIsSingleton(type: TimelineOverlay['type']): type is SingletonOverlay['type'] { return type === 'TIMESTAMP_MARKER' || type === 'ENTRY_SELECTED'; } /** * 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.Handlers.Types.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. 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: TimelineOverlay, 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: 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: 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<TimelineOverlay, HTMLElement|null>(); #singletonOverlays = new Map<SingletonOverlay['type'], TimelineOverlay>(); // 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: 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<boolean>; /** * 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<T extends TimelineOverlay>(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<T extends TimelineOverlay>(existingOverlay: T, newData: Partial<T>): 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<T>, 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: 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); } } /** * @returns the list of overlays associated with a given entry. */ overlaysForEntry(entry: OverlayEntry): TimelineOverlay[] { const matches: TimelineOverlay[] = []; 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: TimelineOverlay): 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: TimelineOverlay['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<T extends TimelineOverlay>(type: T['type']): Array<NoInfer<T>> { if (overlayTypeIsSingleton(type)) { const singleton = this.#singletonOverlays.get(type); if (singleton) { return [singleton as T]; } return []; } const matches: T[] = []; function overlayIsOfType(overlay: TimelineOverlay): 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(): TimelineOverlay[] { return [...this.#overlaysToElements.keys()]; } /** * Removes the provided overlay from the list of overlays and destroys any * DOM associated with it. */ remove(overlay: TimelineOverlay): 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(); // 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<void> { const timeRangeOverlays: TimeRangeLabel[] = []; const timingsMarkerOverlays: TimingsMarker[] = []; 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 (overlay.type === 'TIMINGS_MARKER') { timingsMarkerOverlays.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 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<TimeRangeLabel, TimeRangeLabel[]>(); for (let i = 0; i < overlaysSorted.length; i++) { const current = overlaysSorted[i]; const overlaps: 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: TimelineOverlay, 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; } default: { Platform.TypeScriptUtilities.assertNever(overlay, `Unknown overlay: ${JSON.stringify(overlay)}`); } } } #positionTimingOverlay(overlay: TimestampMarker|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: TimespanBreakdown, element: HTMLElement): void { if (overlay.sections.length === 0) { return; } const component = element.querySelector('devtools-timespan-breakdown-overlay'); const elementSections = component?.renderedSections() ?? []; // 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; element.style.left = `${leftEdgePixel}px`; element.style.width = `${rangeWidth}px`; if (elementSections.length === 0) { return; } let count = 0; 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; const sectionElement = elementSections[count]; sectionElement.style.left = `${leftPixel}px`; sectionElement.style.width = `${rangeWidth}px`; count++; } // 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; element.style.maxHeight = `${MAX_BOX_HEIGHT}px`; 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; element.style.top = `${top}px`; } 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; element.style.top = `${top}px`; } } } /** * 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: 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: 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: 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`; } /** * Positions an EntryLabel overlay * @param overlay - the EntrySelected overlay that we need to position. * @param element - the DOM element representing the overlay */ #positionEntryLabelOverlay(overlay: EntryLabel, element: HTMLElement): number|null { // Because the entry outline is a common Overlay pattern, get the wrapper of the entry // that comes with the EntryLabel Overlay and pass it into the `positionEntryBorderOutlineType` // to draw and position it. The other parts of EntryLabel are drawn by the `EntryLabelOverlay` class. const component = element.querySelector('devtools-entry-label-overlay'); if (!component) { return null; } const entryWrapper = component.entryHighlightWrapper(); if (!entryWrapper) { return null; } const {entryHeight, entryWidth, cutOffHeight = 0, x, y} = this.#positionEntryBorderOutlineType(overlay.entry, entryWrapper) || {}; if (!entryHeight || !entryWidth || x === null || !y) { return null; } // 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_AND_CONNECTOR_HEIGHT}px`; element.style.left = `${x}px`; element.style.width = `${entryWidth}px`; return entryHeight - cutOffHeight; } #positionCandyStripedTimeRange(overlay: 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; /