chrome-devtools-frontend
Version:
Chrome DevTools UI
2,115 lines • 85.3 kB
text/typescript
// 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 */
/* eslint-disable @devtools/no-lit-render-outside-of-view */
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 {html, render} from '../../../ui/lit/lit.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_);
const {widget} = UI.Widget;
/**
* 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<Trace.Types.Overlays.Overlay, HTMLElement|null>();
#singletonOverlays = new Map<SingletonOverlay['type'], Trace.Types.Overlays.Overlay>();
// 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<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 Trace.Types.Overlays.Overlay>(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 Trace.Types.Overlays.Overlay>(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: 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<T extends Trace.Types.Overlays.Overlay>(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: 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<void> {
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<Trace.Types.Overlays.TimeRangeLabel, Trace.Types.Overlays.TimeRangeLabel[]>();
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 isPositionedByEvent =
overlay.entry && (overlay.renderLocation === 'BELOW_EVENT' || overlay.renderLocation === 'ABOVE_EVENT');
element.classList.toggle('positioned-by-event', Boolean(isPositionedByEvent));
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;
widget.maxHeight = height;
}
}
}
/**
* 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<HTMLElement>('.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': {
// clang-format off
render(html`${widget(Components.TimespanBreakdownOverlay.TimespanBreakdownOverlay, {
isBelowEntry: overlay.renderLocation === 'BELOW_EVENT',
canvasRect: this.#charts.mainChart.canvasBoundingClientRect(),
sections: overlay.sections,
})}`, overlayElement);
// clang-format on
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.isSoftFirstContentfulPaint(event)) {
name = 'Soft FCP';
} 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<Trace.Types.Timing.Micro> {
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');
}
}