chrome-devtools-frontend
Version:
Chrome DevTools UI
334 lines (292 loc) ⢠15.5 kB
text/typescript
// Copyright 2023 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 Trace from '../../models/trace/trace.js';
import * as ComponentHelpers from '../../ui/components/helpers/helpers.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as ThemeSupport from '../../ui/legacy/theme_support/theme_support.js';
import {buildGroupStyle, buildTrackHeader} from './AppenderUtils.js';
import {
type CompatibilityTracksAppender,
type DrawOverride,
type PopoverInfo,
type TrackAppender,
type TrackAppenderName,
VisualLoggingTrackName,
} from './CompatibilityTracksAppender.js';
import * as Utils from './utils/utils.js';
const UIStrings = {
/**
*@description Text in Timeline Flame Chart Data Provider of the Performance panel
*/
layoutShifts: 'Layout shifts',
/**
*@description Text in Timeline Flame Chart Data Provider of the Performance panel
*/
layoutShiftCluster: 'Layout shift cluster',
/**
*@description Text in Timeline Flame Chart Data Provider of the Performance panel
*/
layoutShift: 'Layout shift',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/LayoutShiftsTrackAppender.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
// Bit of a hack: LayoutShifts are instant events, so have no duration. But
// OPP doesn't do well at making tiny events easy to spot and click. So we
// set it to a small duration so that the user is able to see and click
// them more easily. Long term we will explore a better UI solution to
// allow us to do this properly and not hack around it.
// TODO: Delete this once the new Layout Shift UI ships out of the TIMELINE_LAYOUT_SHIFT_DETAILS experiment
export const LAYOUT_SHIFT_SYNTHETIC_DURATION = Trace.Types.Timing.Micro(5_000);
export class LayoutShiftsTrackAppender implements TrackAppender {
readonly appenderName: TrackAppenderName = 'LayoutShifts';
#compatibilityBuilder: CompatibilityTracksAppender;
#parsedTrace: Readonly<Trace.Handlers.Types.ParsedTrace>;
constructor(compatibilityBuilder: CompatibilityTracksAppender, parsedTrace: Trace.Handlers.Types.ParsedTrace) {
this.#compatibilityBuilder = compatibilityBuilder;
this.#parsedTrace = parsedTrace;
}
/**
* Appends into the flame chart data the data corresponding to the
* layout shifts track.
* @param trackStartLevel the horizontal level of the flame chart events where
* the track's events will start being appended.
* @param expanded wether the track should be rendered expanded.
* @returns the first available level to append more data after having
* appended the track's events.
*/
appendTrackAtLevel(trackStartLevel: number, expanded?: boolean): number {
if (this.#parsedTrace.LayoutShifts.clusters.length === 0) {
return trackStartLevel;
}
this.#appendTrackHeaderAtLevel(trackStartLevel, expanded);
return this.#appendLayoutShiftsAtLevel(trackStartLevel);
}
/**
* Adds into the flame chart data the header corresponding to the
* layout shifts track. A header is added in the shape of a group in the
* flame chart data. A group has a predefined style and a reference
* to the definition of the legacy track (which should be removed
* in the future).
* @param currentLevel the flame chart level at which the header is
* appended.
*/
#appendTrackHeaderAtLevel(currentLevel: number, expanded?: boolean): void {
const style = buildGroupStyle({collapsible: false});
const group = buildTrackHeader(
VisualLoggingTrackName.LAYOUT_SHIFTS, currentLevel, i18nString(UIStrings.layoutShifts), style,
/* selectable= */ true, expanded);
this.#compatibilityBuilder.registerTrackForGroup(group, this);
}
/**
* Adds into the flame chart data all the layout shifts. These are taken from
* the clusters that are collected in the LayoutShiftsHandler.
* @param currentLevel the flame chart level from which layout shifts will
* be appended.
* @returns the next level after the last occupied by the appended
* layout shifts (the first available level to append more data).
*/
#appendLayoutShiftsAtLevel(currentLevel: number): number {
const allClusters = this.#parsedTrace.LayoutShifts.clusters;
this.#compatibilityBuilder.appendEventsAtLevel(allClusters, currentLevel, this);
const allLayoutShifts = this.#parsedTrace.LayoutShifts.clusters.flatMap(cluster => cluster.events);
void this.preloadScreenshots(allLayoutShifts);
return this.#compatibilityBuilder.appendEventsAtLevel(allLayoutShifts, currentLevel, this);
}
/*
------------------------------------------------------------------------------------
The following methods are invoked by the flame chart renderer to query features about
events on rendering.
------------------------------------------------------------------------------------
*/
/**
* Gets the color an event added by this appender should be rendered with.
*/
colorForEvent(event: Trace.Types.Events.Event): string {
const renderingColor = ThemeSupport.ThemeSupport.instance().getComputedValue('--app-color-rendering');
if (Trace.Types.Events.isSyntheticLayoutShiftCluster(event)) {
const parsedColor = Common.Color.parse(renderingColor);
if (parsedColor) {
const colorWithAlpha = parsedColor.setAlpha(0.5).asString(Common.Color.Format.RGBA);
return colorWithAlpha;
}
}
return renderingColor;
}
setPopoverInfo(event: Trace.Types.Events.Event, info: PopoverInfo): void {
const score = Trace.Types.Events.isSyntheticLayoutShift(event) ? event.args.data?.weighted_score_delta ?? 0 :
Trace.Types.Events.isSyntheticLayoutShiftCluster(event) ? event.clusterCumulativeScore :
-1;
// Score isn't a duration, but the UI works anyhow.
info.formattedTime = score.toFixed(4);
info.title = Trace.Types.Events.isSyntheticLayoutShift(event) ? i18nString(UIStrings.layoutShift) :
Trace.Types.Events.isSyntheticLayoutShiftCluster(event) ? i18nString(UIStrings.layoutShiftCluster) :
event.name;
if (Trace.Types.Events.isSyntheticLayoutShift(event)) {
// Screenshots are max 500x500 naturally, but on a laptop in dock-to-right, 500px tall usually doesn't fit.
// In the future, we may investigate a way to dynamically scale this tooltip content per available space.
const maxSize = new UI.Geometry.Size(510, 400);
const vizElem = LayoutShiftsTrackAppender.createShiftViz(event, this.#parsedTrace, maxSize);
if (vizElem) {
info.additionalElements.push(vizElem);
}
}
}
getDrawOverride(event: Trace.Types.Events.Event): DrawOverride|undefined {
if (Trace.Types.Events.isSyntheticLayoutShift(event)) {
const score = event.args.data?.weighted_score_delta || 0;
// `buffer` is how much space is between the actual diamond shape and the
// edge of its select box. The select box will have a constant size
// so a larger `buffer` will create a smaller diamond.
//
// This logic will scale the size of the diamond based on the layout shift score.
// A LS score of >=0.1 will create a diamond of maximum size
// A LS score of ~0 will create a diamond of minimum size (exactly 0 should not happen in practice)
const bufferScale = 1 - Math.min(score / 0.10, 1);
return (context, x, y, _width, levelHeight, _, transformColor) => {
// levelHeight is 17px, so this math translates to a minimum diamond size of 5.6px tall.
const maxBuffer = levelHeight / 3;
const buffer = bufferScale * maxBuffer;
const boxSize = levelHeight;
const halfSize = boxSize / 2;
context.save();
context.beginPath();
context.moveTo(x, y + buffer);
context.lineTo(x + halfSize - buffer, y + halfSize);
context.lineTo(x, y + levelHeight - buffer);
context.lineTo(x - halfSize + buffer, y + halfSize);
context.closePath();
context.fillStyle = transformColor(this.colorForEvent(event));
context.fill();
context.restore();
return {
x: x - halfSize,
width: boxSize,
};
};
}
if (Trace.Types.Events.isSyntheticLayoutShiftCluster(event)) {
return (context, x, y, width, levelHeight, _, transformColor) => {
const barHeight = levelHeight * 0.2;
const barY = y + (levelHeight - barHeight) / 2 + 0.5;
context.fillStyle = transformColor(this.colorForEvent(event));
context.fillRect(x, barY, width - 0.5, barHeight - 1);
return {x, width, z: -1};
};
}
return;
}
preloadScreenshots(events: Trace.Types.Events.SyntheticLayoutShift[]): Promise<Array<void|undefined>> {
const screenshotsToLoad = new Set<Trace.Types.Events.LegacySyntheticScreenshot|Trace.Types.Events.Screenshot>();
for (const event of events) {
const shots = event.parsedData.screenshots;
shots.before && screenshotsToLoad.add(shots.before);
shots.after && screenshotsToLoad.add(shots.after);
}
const screenshots = Array.from(screenshotsToLoad);
return Utils.ImageCache.preload(screenshots);
}
titleForEvent(_event: Trace.Types.Events.Event): string {
/**
* This method defines the titles drawn on the track for the events in this
* appender. In the case of the Layout Shifts, we do not draw any titles. We
* draw layout shifts which are represented as diamonds, and clusters, which
* are represented as the purple lines through the diamonds. We do not want
* to put any text on top of these, hence overriding this method to return
* the empty string.
*/
return '';
}
static createShiftViz(
event: Trace.Types.Events.SyntheticLayoutShift, parsedTrace: Trace.Handlers.Types.ParsedTrace,
maxSize: UI.Geometry.Size): HTMLElement|undefined {
const screenshots = event.parsedData.screenshots;
const {viewportRect, devicePixelRatio: dpr} = parsedTrace.Meta;
const vizContainer = document.createElement('div');
vizContainer.classList.add('layout-shift-viz');
const beforeImg = screenshots.before && Utils.ImageCache.getOrQueue(screenshots.before);
const afterImg = screenshots.after && Utils.ImageCache.getOrQueue(screenshots.after);
if (!beforeImg || !afterImg || !viewportRect || dpr === undefined) {
return;
}
/** 1 of 3 scaling factors.
* The Layout Instability API in Blink, which reports the LayoutShift trace events, is not based on CSS pixels but
* physical pixels. As such the values in the impacted_nodes field need to be normalized to CSS units in order to
* map them to the viewport dimensions, which we get in CSS pixels. We do that by dividing the values by the devicePixelRatio.
* See https://crbug.com/1300309
*/
const toCssPixelRect = (rect: Trace.Types.Events.TraceRect): DOMRect => {
return new DOMRect(rect[0] / dpr, rect[1] / dpr, rect[2] / dpr, rect[3] / dpr);
};
// 2 of 3 scaling factors. Turns CSS pixels into pixels relative to the size of the screenshot image's natural size.
const screenshotImageScaleFactor =
Math.min(beforeImg.naturalWidth / viewportRect.width, beforeImg.naturalHeight / viewportRect.height, 1);
// 3 of 3 scaling factors. We can constrain this UI by a maxSize in case we want it smaller.
// If this is being size constrained, it needs to be done in JS (rather than css max-width, etc)....
// That's because this function is complete before it's added to the DOM.. so we can't query offsetHeight for its resolved sizeā¦
const maxSizeScaleFactor =
Math.min(maxSize.width / beforeImg.naturalWidth, maxSize.height / beforeImg.naturalHeight, 1);
for (const elem of [vizContainer, afterImg, beforeImg]) {
elem.style.width = `${beforeImg.naturalWidth * maxSizeScaleFactor}px`;
elem.style.height = `${beforeImg.naturalHeight * maxSizeScaleFactor}px`;
}
const beforeRects = event.args.data?.impacted_nodes?.map(node => toCssPixelRect(node.old_rect)) ?? [];
const afterRects = event.args.data?.impacted_nodes?.map(node => toCssPixelRect(node.new_rect)) ?? [];
function startVizAnimation(): void {
if (!beforeImg || !afterImg) {
return;
}
// If image is reused, drop existing anims
[beforeImg, afterImg].flatMap(img => img.getAnimations()).forEach(a => a.cancel());
const easing = 'ease-out';
const vizAnimOpts: KeyframeAnimationOptions = {
duration: 3000,
iterations: Infinity,
fill: 'forwards',
easing,
};
// Using keyframe offsets to add "delay" to both the start and the end.
// https://drafts.csswg.org/web-animations-1/#:~:text=Keyframe%20offsets%20can%20be%20specified%20using%20either%20form%20as%20illustrated%20below%3A
// Animate the "after" screenshot's opacity in.
afterImg.animate({opacity: [0, 0, 1, 1, 1], easing}, vizAnimOpts);
const getRectPosition = (rect: DOMRect): Keyframe => ({
left: `${rect.x * maxSizeScaleFactor * screenshotImageScaleFactor}px`,
top: `${rect.y * maxSizeScaleFactor * screenshotImageScaleFactor}px`,
width: `${rect.width * maxSizeScaleFactor * screenshotImageScaleFactor}px`,
height: `${rect.height * maxSizeScaleFactor * screenshotImageScaleFactor}px`,
opacity: 0.7,
outlineWidth: '1px',
easing,
});
// Create and position individual rects representing each impacted_node within a shift
beforeRects.forEach((beforeRect, i) => {
const afterRect = afterRects[i];
const rectEl = document.createElement('div');
rectEl.classList.add('layout-shift-viz-rect');
vizContainer.appendChild(rectEl);
let beforePos = getRectPosition(beforeRect);
let afterPos = getRectPosition(afterRect);
afterPos.opacity = 0.4;
// Edge case: if either before or after is 0x0x0x0, then we'll fade it in/out in the same location.
if ([beforeRect.width, beforeRect.height, beforeRect.x, beforeRect.y].every(v => v === 0)) {
beforePos = {...afterPos};
beforePos.opacity = '0';
}
if ([afterRect.width, afterRect.height, afterRect.x, afterRect.y].every(v => v === 0)) {
afterPos = {...beforePos};
afterPos.opacity = '0';
}
// Keep these keyframe offsets sync'd with other animate() ones above.
// The 4px outline slightly pulses the rect so it's easier to distinguish
rectEl.animate([beforePos, beforePos, {...afterPos, outlineWidth: '4px'}, afterPos, afterPos], vizAnimOpts);
});
}
// If not done within the render lifecycle, getAnimations() falsely returns [] which allows animations to pile up on the same screenshot
void ComponentHelpers.ScheduledRender.scheduleRender(vizContainer, () => startVizAnimation());
vizContainer.append(beforeImg, afterImg);
return vizContainer;
}
}