UNPKG

chrome-devtools-frontend

Version:
511 lines (433 loc) 15.4 kB
// Copyright (c) 2020 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. import * as Host from '../../../core/host/host.js'; import * as i18n from '../../../core/i18n/i18n.js'; import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js'; import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js'; import * as LitHtml from '../../../ui/lit-html/lit-html.js'; const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance(); import {WebVitalsEventLane, WebVitalsTimeboxLane} from './WebVitalsLane.js'; import type {WebVitalsTooltip} from './WebVitalsTooltip.js'; const UIStrings = { /** *@description Label for the First Contentful Paint lane */ fcp: 'FCP', /** *@description Label for the Largest Contentful Paint lane */ lcp: 'LCP', /** *@description Label for the Layout Shifts lane */ ls: 'LS', /** *@description Label for the Long Tasks lane */ longTasks: 'Long Tasks', /** *@description Label for the Long Tasks overlay */ longTask: 'Long Task', /** *@description Label for the First Contentful Paint overlay */ firstContentfulPaint: 'First Contentful Paint', /** *@description Label for the Largest Contentful Paint overlay */ largestContentfulPaint: 'Largest Contentful Paint', /** *@description Label to describe the range in which the rating for the value is considered good */ good: 'Good', /** *@description Label to describe the range in which the rating for the value is considered to need improvement */ needsImprovement: 'Needs improvement', /** *@description Label to describe the range in which the rating for the value is considered poor */ poor: 'Poor', }; const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/WebVitalsTimeline.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); declare global { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface HTMLElementTagNameMap { 'devtools-timeline-webvitals': WebVitalsTimeline; } } export interface Event { timestamp: number; } export interface Timebox { start: number; duration: number; } export interface WebVitalsFCPEvent { timestamp: number; } export interface WebVitalsLCPEvent { timestamp: number; } export interface WebVitalsLayoutShiftEvent { timestamp: number; } interface WebVitalsTimelineTask { start: number; duration: number; } interface WebVitalsTimelineData { startTime: number; duration: number; fcps?: WebVitalsFCPEvent[]; lcps?: WebVitalsLCPEvent[]; layoutShifts?: WebVitalsLayoutShiftEvent[]; longTasks?: WebVitalsTimelineTask[]; mainFrameNavigations?: number[]; maxDuration?: number; } export interface Marker { type: MarkerType; timestamp: number; timestampLabel: string; timestampMetrics: TextMetrics; widthIncludingLabel: number; widthIncludingTimestamp: number; } export const enum MarkerType { Good = 'Good', Medium = 'Medium', Bad = 'Bad', } export const LINE_HEIGHT = 24; const NUMBER_OF_LANES = 5; const FCP_GOOD_TIMING = 2000; const FCP_MEDIUM_TIMING = 4000; const LCP_GOOD_TIMING = 2500; const LCP_MEDIUM_TIMING = 4000; export const LONG_TASK_THRESHOLD = 50; type Constructor<T> = { new (...args: unknown[]): T, }; // eslint-disable-next-line export function assertInstanceOf<T>(instance: any, constructor: Constructor<T>): asserts instance is T { if (!(instance instanceof constructor)) { throw new TypeError(`Instance expected to be of type ${constructor.name} but got ${instance.constructor.name}`); } } export class WebVitalsTimeline extends HTMLElement { static readonly litTagName = LitHtml.literal`devtools-timeline-webvitals`; private readonly shadow = this.attachShadow({mode: 'open'}); private mainFrameNavigations: readonly number[] = []; private startTime = 0; private duration = 1000; private maxDuration = 1000; private width = 0; private height = 0; private canvas: HTMLCanvasElement; private hoverLane: number|null = null; private fcpLane: WebVitalsEventLane; private lcpLane: WebVitalsEventLane; private layoutShiftsLane: WebVitalsEventLane; private longTasksLane: WebVitalsTimeboxLane; private context: CanvasRenderingContext2D; private animationFrame: number|null = null; private overlay: WebVitalsTooltip; constructor() { super(); this.canvas = document.createElement('canvas'); this.canvas.style.width = '100%'; this.canvas.style.height = `${Math.max(LINE_HEIGHT * NUMBER_OF_LANES, 120)}px`; this.shadow.appendChild(this.canvas); this.canvas.addEventListener('pointermove', this.handlePointerMove.bind(this)); this.canvas.addEventListener('pointerout', this.handlePointerOut.bind(this)); this.canvas.addEventListener('click', this.handleClick.bind(this)); const context = this.canvas.getContext('2d'); assertInstanceOf(context, CanvasRenderingContext2D); this.context = context; this.fcpLane = new WebVitalsEventLane( this, i18nString(UIStrings.fcp), e => this.getMarkerTypeForFCPEvent(e), this.getFCPMarkerOverlay); this.lcpLane = new WebVitalsEventLane( this, i18nString(UIStrings.lcp), e => this.getMarkerTypeForLCPEvent(e), this.getLCPMarkerOverlay); this.layoutShiftsLane = new WebVitalsEventLane(this, i18nString(UIStrings.ls), _ => MarkerType.Bad); this.longTasksLane = new WebVitalsTimeboxLane(this, i18nString(UIStrings.longTasks), this.getLongTaskOverlay); this.overlay = document.createElement('devtools-timeline-webvitals-tooltip'); this.overlay.style.position = 'absolute'; this.overlay.style.visibility = 'hidden'; this.ownerDocument.body.appendChild(this.overlay); } set data(data: WebVitalsTimelineData) { this.startTime = data.startTime || this.startTime; this.duration = data.duration || this.duration; this.maxDuration = data.maxDuration || this.maxDuration; this.mainFrameNavigations = data.mainFrameNavigations || this.mainFrameNavigations; if (data.fcps) { this.fcpLane.setEvents(data.fcps); } if (data.lcps) { this.lcpLane.setEvents(data.lcps); } if (data.layoutShifts) { this.layoutShiftsLane.setEvents(data.layoutShifts); } if (data.longTasks) { this.longTasksLane.setTimeboxes(data.longTasks); } this.scheduleRender(); } getContext(): CanvasRenderingContext2D { return this.context; } getLineHeight(): number { return LINE_HEIGHT; } hideOverlay(): void { this.overlay.style.visibility = 'hidden'; } showOverlay(content: LitHtml.TemplateResult): void { this.overlay.data = {content}; this.overlay.style.visibility = 'visible'; } private handlePointerMove(e: MouseEvent): void { this.updateOverlayPosition(e.clientX, e.clientY); const x = e.offsetX, y = e.offsetY; const lane = Math.floor(y / LINE_HEIGHT); this.hoverLane = lane; this.fcpLane.handlePointerMove(this.hoverLane === 1 ? x : null); this.lcpLane.handlePointerMove(this.hoverLane === 2 ? x : null); this.layoutShiftsLane.handlePointerMove(this.hoverLane === 3 ? x : null); this.longTasksLane.handlePointerMove(this.hoverLane === 4 ? x : null); this.scheduleRender(); } private updateOverlayPosition(clientX: number, clientY: number): void { coordinator.read(() => { const bb1 = this.getBoundingClientRect(); const bb2 = this.overlay.getBoundingClientRect(); const x = clientX + 10 + bb2.width > bb1.x + bb1.width ? clientX - bb2.width - 10 : clientX + 10; coordinator.write(() => { this.overlay.style.top = `${clientY + 10}px`; this.overlay.style.left = `${x}px`; }); }); } private handlePointerOut(_: MouseEvent): void { this.fcpLane.handlePointerMove(null); this.lcpLane.handlePointerMove(null); this.layoutShiftsLane.handlePointerMove(null); this.longTasksLane.handlePointerMove(null); this.scheduleRender(); } private handleClick(e: MouseEvent): void { const x = e.offsetX; this.focus(); this.fcpLane.handleClick(this.hoverLane === 1 ? x : null); this.lcpLane.handleClick(this.hoverLane === 2 ? x : null); this.layoutShiftsLane.handleClick(this.hoverLane === 3 ? x : null); this.longTasksLane.handleClick(this.hoverLane === 4 ? x : null); this.scheduleRender(); } /** * Transform from time to pixel offset * @param x */ tX(x: number): number { return (x - this.startTime) / this.duration * this.width; } /** * Transform from duration to pixels * @param duration */ tD(duration: number): number { return duration / this.duration * this.width; } setSize(width: number, height: number): void { const scale = window.devicePixelRatio; this.width = width; this.height = Math.max(height, 120); this.canvas.width = Math.floor(this.width * scale); this.canvas.height = Math.floor(this.height * scale); this.context.scale(scale, scale); this.style.width = this.width + 'px'; this.style.height = this.height + 'px'; this.render(); } connectedCallback(): void { this.style.display = 'block'; this.tabIndex = 0; const boundingClientRect = this.canvas.getBoundingClientRect(); const width = boundingClientRect.width; const height = boundingClientRect.height; this.setSize(width, height); this.render(); } disconnectedCallback(): void { this.overlay.remove(); } private getMarkerTypeForFCPEvent(event: WebVitalsFCPEvent): MarkerType { const t = this.getTimeSinceLastMainFrameNavigation(event.timestamp); if (t <= FCP_GOOD_TIMING) { return MarkerType.Good; } if (t <= FCP_MEDIUM_TIMING) { return MarkerType.Medium; } return MarkerType.Bad; } private getMarkerTypeForLCPEvent(event: WebVitalsLCPEvent): MarkerType { const t = this.getTimeSinceLastMainFrameNavigation(event.timestamp); if (t <= LCP_GOOD_TIMING) { return MarkerType.Good; } if (t <= LCP_MEDIUM_TIMING) { return MarkerType.Medium; } return MarkerType.Bad; } private getFCPMarkerOverlay(): LitHtml.TemplateResult { return LitHtml.html` <table class="table"> <thead> <td colspan="3" class="title">${i18nString(UIStrings.firstContentfulPaint)}</td> </thead> <tbody> <tr> <td><span class="good"></span></td> <td>${i18nString(UIStrings.good)}</td> <td> ≤ ${i18n.TimeUtilities.millisToString(FCP_GOOD_TIMING)}</td> </tr> <tr> <td><span class="medium"></span></td> <td>${i18nString(UIStrings.needsImprovement)}</td> <td></td> </tr> <tr> <td><span class="bad"></span></td> <td>${i18nString(UIStrings.poor)}</td> <td>> ${i18n.TimeUtilities.millisToString(FCP_MEDIUM_TIMING)}</td> </tr> </tbody> </table> `; } private getLCPMarkerOverlay(): LitHtml.TemplateResult { return LitHtml.html` <table class="table"> <thead> <td colspan="3" class="title">${i18nString(UIStrings.largestContentfulPaint)}</td> </thead> <tbody> <tr> <td><span class="good"></span></td> <td>${i18nString(UIStrings.good)}</td> <td> ≤ ${i18n.TimeUtilities.millisToString(LCP_GOOD_TIMING)}</td> </tr> <tr> <td><span class="medium"></span></td> <td>${i18nString(UIStrings.needsImprovement)}</td> <td></td> </tr> <tr> <td><span class="bad"></span></td> <td>${i18nString(UIStrings.poor)}</td> <td>> ${i18n.TimeUtilities.millisToString(LCP_MEDIUM_TIMING)}</td> </tr> </tbody> </table> `; } private getLongTaskOverlay(timebox: Timebox): LitHtml.TemplateResult { return LitHtml.html` <table class="table"> <thead> <td colspan="3" class="title"> ${i18nString(UIStrings.longTask)} <span class="small"> ${i18n.TimeUtilities.millisToString(timebox.duration)} </span> </td> </thead> <tbody> <tr> <td><span class="good"></span></td> <td>${i18nString(UIStrings.good)}</td> <td>≤ ${i18n.TimeUtilities.millisToString(LONG_TASK_THRESHOLD)}</td> </tr> <tr> <td><span class="bad"></span></td> <td>${i18nString(UIStrings.poor)}</td> <td>> ${i18n.TimeUtilities.millisToString(LONG_TASK_THRESHOLD)}</td> </tr> </tbody> </table> `; } private renderMainFrameNavigations(markers: readonly number[]): void { this.context.save(); this.context.strokeStyle = 'blue'; this.context.beginPath(); for (const marker of markers) { this.context.moveTo(this.tX(marker), 0); this.context.lineTo(this.tX(marker), this.height); } this.context.stroke(); this.context.restore(); } getTimeSinceLastMainFrameNavigation(time: number): number { let i = 0, prev = 0; while (i < this.mainFrameNavigations.length && this.mainFrameNavigations[i] <= time) { prev = this.mainFrameNavigations[i]; i++; } return time - prev; } render(): void { this.context.save(); this.context.clearRect(0, 0, this.width, this.height); this.context.strokeStyle = '#dadce0'; // Render the grid in the background. this.context.beginPath(); for (let i = 0; i < NUMBER_OF_LANES; i++) { this.context.moveTo(0, (i * LINE_HEIGHT) + 0.5); this.context.lineTo(this.width, (i * LINE_HEIGHT) + 0.5); } this.context.moveTo(0, NUMBER_OF_LANES * LINE_HEIGHT - 0.5); this.context.lineTo(this.width, NUMBER_OF_LANES * LINE_HEIGHT - 0.5); this.context.stroke(); this.context.restore(); // Render the WebVitals label. this.context.save(); this.context.font = '11px ' + Host.Platform.fontFamily(); const text = this.context.measureText('Web Vitals'); const height = text.actualBoundingBoxAscent - text.actualBoundingBoxDescent; this.context.fillStyle = '#202124'; this.context.fillText('Web Vitals', 6, 4 + height); this.context.restore(); // Render all the lanes. this.context.save(); this.context.translate(0, Number(LINE_HEIGHT)); this.fcpLane.render(); this.context.translate(0, Number(LINE_HEIGHT)); this.lcpLane.render(); this.context.translate(0, Number(LINE_HEIGHT)); this.layoutShiftsLane.render(); this.context.translate(0, Number(LINE_HEIGHT)); this.longTasksLane.render(); this.context.restore(); this.renderMainFrameNavigations(this.mainFrameNavigations); } private scheduleRender(): void { if (this.animationFrame) { return; } this.animationFrame = window.requestAnimationFrame(() => { this.animationFrame = null; this.render(); }); } } ComponentHelpers.CustomElements.defineComponent('devtools-timeline-webvitals', WebVitalsTimeline);