// 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. import * as i18n from '../../../../core/i18n/i18n.js'; import type * as Trace from '../../../../models/trace/trace.js'; import * as UI from '../../../../ui/legacy/legacy.js'; import {Directives, html, nothing, render, type TemplateResult} from '../../../../ui/lit/lit.js'; import timespanBreakdownOverlayStyles from './timespanBreakdownOverlay.css.js'; export interface Input { sections: Trace.Types.Overlays.TimespanBreakdownEntryBreakdown[]|null; positions: SectionPosition[]; left: number|null; width: number|null; maxHeight: number|null; top: number|null; className: string; } export interface SectionPosition { left: number|null; width: number|null; } type View = (input: Input, _output: undefined, target: HTMLElement) => void; const renderSection = (section: Trace.Types.Overlays.TimespanBreakdownEntryBreakdown, position: SectionPosition): TemplateResult => { const style = Directives.styleMap( {left: position ? `${position.left}px` : undefined, width: position ? `${position.width}px` : undefined}); // clang-format off return html`
${ section.showDuration ? html`${i18n.TimeUtilities.formatMicroSecondsAsMillisFixed(section.bounds.range)} ` : nothing }
`; // clang-format on }; export const DEFAULT_VIEW = (input: Input, _output: undefined, target: HTMLElement): void => { const style = Directives.styleMap({ left: input.left ? `${input.left}px` : undefined, width: input.width ? `${input.width}px` : undefined, top: input.top ? `${input.top}px` : undefined, maxHeight: input.maxHeight ? `${input.maxHeight}px` : undefined, position: 'relative' }); // clang-format off render( html`
${input.sections?.map((curr, index) => { return renderSection(curr, input.positions[index]); })}
`, target); // clang-format off }; export class TimespanBreakdownOverlay extends UI.Widget.Widget { #canvasRect: DOMRect|null = null; #sections: Trace.Types.Overlays.TimespanBreakdownEntryBreakdown[]|null = null; #sectionsPositions: SectionPosition[] = []; #left: number|null = null; #width: number|null = null; #maxHeight: number|null = null; #top: number|null = null; #view: View; constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) { super(element, {classes: ['devtools-timespan-breakdown-overlay']}); this.#view = view; this.requestUpdate(); } set top(top: number) { this.#top = top; this.requestUpdate(); } set maxHeight(maxHeight: number) { this.#maxHeight = maxHeight; this.requestUpdate(); } set width(width: number) { this.#width = width; this.requestUpdate(); } set left(left: number) { this.#left = left; this.requestUpdate(); } set isBelowEntry(isBelow: boolean) { this.element.classList.toggle('is-below', isBelow); } set canvasRect(rect: DOMRect|null) { if (this.#canvasRect && rect && this.#canvasRect.width === rect.width && this.#canvasRect.height === rect.height) { return; } this.#canvasRect = rect; this.requestUpdate(); } set widths(widths: SectionPosition[]) { if (widths === this.#sectionsPositions) { return; } this.#sectionsPositions = widths; this.requestUpdate(); } set sections(sections: Trace.Types.Overlays.TimespanBreakdownEntryBreakdown[]|null) { if (sections === this.#sections) { return; } this.#sections = sections; this.requestUpdate(); } /** * We use this method after the overlay has been positioned in order to move * the section label as required to keep it on screen. * If the label is off to the left or right, we fix it to that corner and * align the text so the label is visible as long as possible. */ checkSectionLabelPositioning(): void { const sections = this.element.querySelectorAll('.timespan-breakdown-overlay-section'); if (!sections) { return; } if (!this.#canvasRect) { return; } // On the RHS of the panel a scrollbar can be shown which means the canvas // has a 9px gap on the right hand edge. We use this value when calculating // values and label positioning from the left hand side in order to be // consistent on both edges of the UI. const paddingForScrollbar = 9; // Fetch the rects for each section and label now, rather than in the loop, // to avoid causing a bunch of recalcStyles const sectionLayoutData = new Map(); for (const section of sections) { const label = section.querySelector('.timespan-breakdown-overlay-label'); if (!label) { continue; } const sectionRect = section.getBoundingClientRect(); const labelRect = label.getBoundingClientRect(); sectionLayoutData.set(section, {sectionRect, labelRect, label}); } const minSectionWidthToShowAnyLabel = 30; // Align the labels for all the breakdown sections. for (const section of sections) { const layoutData = sectionLayoutData.get(section); if (!layoutData) { break; } const {labelRect, sectionRect, label} = layoutData; const labelHidden = sectionRect.width < minSectionWidthToShowAnyLabel; // Subtract 5 from the section width to allow a tiny bit of padding. const labelTruncated = sectionRect.width - 5 <= labelRect.width; // We differentiate between hidden + truncated; if it is truncated we // will show the text with ellipsis for overflow, but if the section is // really small we just hide the label entirely. label.classList.toggle('labelHidden', labelHidden); label.classList.toggle('labelTruncated', labelTruncated); if (labelHidden || labelTruncated) { // Label is hidden or doesn't fully fit, so we don't need to do the // logic to left/right align if it needs it. continue; } // Check if label is off the LHS of the screen. const labelLeftMarginToCenter = (sectionRect.width - labelRect.width) / 2; const newLabelX = sectionRect.x + labelLeftMarginToCenter; const labelOffLeftOfScreen = newLabelX < this.#canvasRect.x; label.classList.toggle('offScreenLeft', labelOffLeftOfScreen); // Check if label is off the RHS of the screen const rightBound = this.#canvasRect.x + this.#canvasRect.width; // The label's right hand edge is the gap from the left of the range to the // label, and then the width of the label. const labelRightEdge = sectionRect.x + labelLeftMarginToCenter + labelRect.width; const labelOffRightOfScreen = labelRightEdge > rightBound; label.classList.toggle('offScreenRight', labelOffRightOfScreen); if (labelOffLeftOfScreen) { // If the label is off the left of the screen, we adjust by the // difference between the X that represents the start of the cavnas, and // the X that represents the start of the overlay. // We then take the absolute value of this - because if the canvas starts // at 0, and the overlay is -200px, we have to adjust the label by +200. // Add on 9 pixels to pad from the left; this is the width of the sidebar // on the RHS so we match it so the label is equally padded on either // side. label.style.marginLeft = `${Math.abs(this.#canvasRect.x - sectionRect.x) + paddingForScrollbar}px`; } else if (labelOffRightOfScreen) { // To calculate how far left to push the label, we take the right hand // bound (the canvas width and subtract the label's width). // Finally, we subtract the X position of the overlay (if the overlay is // 200px within the view, we don't need to push the label that 200px too // otherwise it will be off-screen) const leftMargin = rightBound - labelRect.width - sectionRect.x; label.style.marginLeft = `${leftMargin}px`; } else { // Keep the label central. label.style.marginLeft = `${labelLeftMarginToCenter}px`; } } } override performUpdate(): void { let className = 'timeline-segment-container'; if (this.#sections) { if(this.#sections.length % 2 === 0) { className += ' even-number-of-sections'; } else { className += ' odd-number-of-sections'; } } this.#view({sections: this.#sections, positions: this.#sectionsPositions, left: this.#left, width: this.#width, top: this.#top, maxHeight: this.#maxHeight, className }, undefined, this.contentElement); this.checkSectionLabelPositioning(); } }