UNPKG

chrome-devtools-frontend

Version:
334 lines (292 loc) • 15.5 kB
// 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; } }