// 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
}
${section.label}
`;
// 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();
}
}