chrome-devtools-frontend
Version:
Chrome DevTools UI
341 lines (277 loc) • 9.66 kB
text/typescript
// 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 '../host/host.js';
import {WebVitalsEventLane, WebVitalsTimeboxLane} from './WebVitalsLane.js';
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 enum Colors {
Good = '#0cce6b',
Medium = '#ffa400',
Bad = '#ff4e42',
}
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 {
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;
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, 'FCP', e => this.getMarkerTypeForFCPEvent(e));
this.lcpLane = new WebVitalsEventLane(this, 'LCP', e => this.getMarkerTypeForLCPEvent(e));
this.layoutShiftsLane = new WebVitalsEventLane(this, 'LS', _ => MarkerType.Bad);
this.longTasksLane = new WebVitalsTimeboxLane(this, 'Long Tasks');
}
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;
}
private handlePointerMove(e: MouseEvent): void {
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 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();
}
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 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();
});
}
}
customElements.define('devtools-timeline-webvitals', WebVitalsTimeline);