chrome-devtools-frontend
Version:
Chrome DevTools UI
1,326 lines (1,169 loc) • 84.1 kB
text/typescript
// 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;
/