chrome-devtools-frontend
Version:
Chrome DevTools UI
525 lines (440 loc) • 16.5 kB
text/typescript
// Copyright 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 Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import type * as SDK from '../../core/sdk/sdk.js';
import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as ThemeSupport from '../../ui/legacy/theme_support/theme_support.js';
import {Bounds, formatMillisecondsToSeconds} from './TickingFlameChartHelpers.js';
const defaultFont = '11px ' + Host.Platform.fontFamily();
function getGroupDefaultTextColor(): string {
return ThemeSupport.ThemeSupport.instance().getComputedValue('--color-text-primary');
}
const DefaultStyle: () => PerfUI.FlameChart.GroupStyle = () => ({
height: 20,
padding: 2,
collapsible: false,
font: defaultFont,
color: getGroupDefaultTextColor(),
backgroundColor: 'rgba(100 0 0 / 10%)',
nestingLevel: 0,
itemsHeight: 20,
shareHeaderLine: false,
useFirstLineForOverview: false,
useDecoratorsForOverview: false,
});
export const HotColorScheme = ['#ffba08', '#faa307', '#f48c06', '#e85d04', '#dc2f02', '#d00000', '#9d0208'];
export const ColdColorScheme = ['#7400b8', '#6930c3', '#5e60ce', '#5390d9', '#4ea8de', '#48bfe3', '#56cfe1', '#64dfdf'];
function calculateFontColor(backgroundColor: string): string {
const parsedColor = Common.Color.parse(backgroundColor)?.as(Common.Color.Format.HSL);
// Dark background needs a light font.
if (parsedColor && parsedColor.l < 0.5) {
return '#eee';
}
return '#444';
}
interface EventHandlers {
setLive: (arg0: number) => number;
setComplete: (arg0: number) => void;
updateMaxTime: (arg0: number) => void;
}
export interface EventProperties {
level: number;
startTime: number;
duration?: number;
name: string;
color?: string;
hoverData?: Object|null;
}
/**
* Wrapper class for each event displayed on the timeline.
*/
export class Event {
private timelineData: PerfUI.FlameChart.FlameChartTimelineData;
private setLive: (arg0: number) => number;
private readonly setComplete: (arg0: number) => void;
private readonly updateMaxTime: (arg0: number) => void;
private selfIndex: number;
private liveInternal: boolean;
title: string;
private colorInternal: string;
private fontColorInternal: string;
private readonly hoverData: Object;
constructor(
timelineData: PerfUI.FlameChart.FlameChartTimelineData, eventHandlers: EventHandlers,
eventProperties: EventProperties|
undefined = {color: undefined, duration: undefined, hoverData: {}, level: 0, name: '', startTime: 0}) {
// These allow the event to privately change it's own data in the timeline.
this.timelineData = timelineData;
this.setLive = eventHandlers.setLive;
this.setComplete = eventHandlers.setComplete;
this.updateMaxTime = eventHandlers.updateMaxTime;
// This is the index in the timelineData arrays we should be writing to.
this.selfIndex = this.timelineData.entryLevels.length;
this.liveInternal = false;
// Can't use the dict||or||default syntax, since NaN is a valid expected duration.
const duration = eventProperties['duration'] === undefined ? 0 : eventProperties['duration'];
(this.timelineData.entryLevels as number[]).push(eventProperties['level'] || 0);
(this.timelineData.entryStartTimes as number[]).push(eventProperties['startTime'] || 0);
(this.timelineData.entryTotalTimes as number[]).push(duration); // May initially push -1
// If -1 was pushed, we need to update it. The set end time method helps with this.
if (duration === -1) {
this.endTime = -1;
}
this.title = eventProperties['name'] || '';
this.colorInternal = eventProperties['color'] || HotColorScheme[0];
this.fontColorInternal = calculateFontColor(this.colorInternal);
this.hoverData = eventProperties['hoverData'] || {};
}
/**
* Render hovertext into the |htmlElement|
*/
decorate(htmlElement: HTMLElement): void {
htmlElement.createChild('span').textContent = `Name: ${this.title}`;
htmlElement.createChild('br');
const startTimeReadable = formatMillisecondsToSeconds(this.startTime, 2);
if (this.liveInternal) {
htmlElement.createChild('span').textContent = `Duration: ${startTimeReadable} - LIVE!`;
} else if (!isNaN(this.duration)) {
const durationReadable = formatMillisecondsToSeconds(this.duration + this.startTime, 2);
htmlElement.createChild('span').textContent = `Duration: ${startTimeReadable} - ${durationReadable}`;
} else {
htmlElement.createChild('span').textContent = `Time: ${startTimeReadable}`;
}
}
/**
* set an event to be "live" where it's ended time is always the chart maximum
* or to be a fixed time.
* @param {number} time
*/
set endTime(time: number) {
// Setting end time to -1 signals that an event becomes live
if (time === -1) {
this.timelineData.entryTotalTimes[this.selfIndex] = this.setLive(this.selfIndex);
this.liveInternal = true;
} else {
this.liveInternal = false;
const duration = time - this.timelineData.entryStartTimes[this.selfIndex];
this.timelineData.entryTotalTimes[this.selfIndex] = duration;
this.setComplete(this.selfIndex);
this.updateMaxTime(time);
}
}
get id(): number {
return this.selfIndex;
}
set level(level: number) {
this.timelineData.entryLevels[this.selfIndex] = level;
}
set color(color: string) {
this.colorInternal = color;
this.fontColorInternal = calculateFontColor(this.colorInternal);
}
get color(): string {
return this.colorInternal;
}
get fontColor(): string {
return this.fontColorInternal;
}
get startTime(): number {
// Round it
return this.timelineData.entryStartTimes[this.selfIndex];
}
get duration(): number {
return this.timelineData.entryTotalTimes[this.selfIndex];
}
get live(): boolean {
return this.liveInternal;
}
}
export class TickingFlameChart extends UI.Widget.VBox {
private intervalTimer: number;
private lastTimestamp: number;
private canTickInternal: boolean;
private ticking: boolean;
private isShown: boolean;
private readonly bounds: Bounds;
private readonly dataProvider: TickingFlameChartDataProvider;
private readonly delegate: TickingFlameChartDelegate;
private readonly chartGroupExpansionSetting: Common.Settings.Setting<Object>;
private readonly chart: PerfUI.FlameChart.FlameChart;
private stoppedPermanently?: boolean;
constructor() {
super();
// set to update once per second _while the tab is active_
this.intervalTimer = 0;
this.lastTimestamp = 0;
this.canTickInternal = true;
this.ticking = false;
this.isShown = false;
// The max bounds for scroll-out.
this.bounds = new Bounds(0, 1000, 30000, 1000);
// Create the data provider with the initial max bounds,
// as well as a function to attempt bounds updating everywhere.
this.dataProvider = new TickingFlameChartDataProvider(this.bounds, this.updateMaxTime.bind(this));
// Delegate doesn't do much for now.
this.delegate = new TickingFlameChartDelegate();
// Chart settings.
this.chartGroupExpansionSetting =
Common.Settings.Settings.instance().createSetting('mediaFlameChartGroupExpansion', {});
// Create the chart.
this.chart =
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// @ts-expect-error
new PerfUI.FlameChart.FlameChart(this.dataProvider, this.delegate, this.chartGroupExpansionSetting);
// TODO: needs to have support in the delegate for supporting this.
this.chart.disableRangeSelection();
// Scrolling should change the current bounds, and repaint the chart.
this.chart.bindCanvasEvent('wheel', e => {
this.onScroll(e as WheelEvent);
});
// Add the chart.
this.chart.show(this.contentElement);
}
/**
* Add a marker with |properties| at |time|.
*/
addMarker(properties: EventProperties): void {
properties['duration'] = NaN;
this.startEvent(properties);
}
/**
* Create an event which will be set to live by default.
*/
startEvent(properties: EventProperties): Event {
// Make sure that an unspecified event gets live duration.
// Have to check for undefined, since NaN is allowed but evaluates to false.
if (properties['duration'] === undefined) {
properties['duration'] = -1;
}
const time = properties['startTime'] || 0;
// Event has to be created before the updateMaxTime call.
const event = this.dataProvider.startEvent(properties);
this.updateMaxTime(time);
return event;
}
/**
* Add a group with |name| that can contain |depth| different tracks.
*/
addGroup(name: Common.UIString.LocalizedString, depth: number): void {
this.dataProvider.addGroup(name, depth);
}
private updateMaxTime(time: number): void {
if (this.bounds.pushMaxAtLeastTo(time)) {
this.updateRender();
}
}
private onScroll(e: WheelEvent): void {
// TODO: is this a good divisor? does it account for high presicision scroll wheels?
// low precisision scroll wheels?
const scrollTickCount = Math.round(e.deltaY / 50);
const scrollPositionRatio = e.offsetX / (e.srcElement as HTMLElement).clientWidth;
if (scrollTickCount > 0) {
this.bounds.zoomOut(scrollTickCount, scrollPositionRatio);
} else {
this.bounds.zoomIn(-scrollTickCount, scrollPositionRatio);
}
this.updateRender();
}
override willHide(): void {
this.isShown = false;
if (this.ticking) {
this.stop();
}
}
override wasShown(): void {
this.isShown = true;
if (this.canTickInternal && !this.ticking) {
this.start();
}
}
set canTick(allowed: boolean) {
this.canTickInternal = allowed;
if (this.ticking && !allowed) {
this.stop();
}
if (!this.ticking && this.isShown && allowed) {
this.start();
}
}
private start(): void {
if (this.lastTimestamp === 0) {
this.lastTimestamp = Date.now();
}
if (this.intervalTimer !== 0 || this.stoppedPermanently) {
return;
}
// 16 ms is roughly 60 fps.
this.intervalTimer = window.setInterval(this.updateRender.bind(this), 16);
this.ticking = true;
}
private stop(permanently: boolean = false): void {
window.clearInterval(this.intervalTimer);
this.intervalTimer = 0;
if (permanently) {
this.stoppedPermanently = true;
}
this.ticking = false;
}
private updateRender(): void {
if (this.ticking) {
const currentTimestamp = Date.now();
const duration = currentTimestamp - this.lastTimestamp;
this.lastTimestamp = currentTimestamp;
this.bounds.addMax(duration);
}
this.dataProvider.updateMaxTime(this.bounds);
this.chart.setWindowTimes(this.bounds.low, this.bounds.high, true);
this.chart.scheduleUpdate();
}
}
/**
* Doesn't do much right now, but can be used in the future for selecting events.
*/
class TickingFlameChartDelegate implements PerfUI.FlameChart.FlameChartDelegate {
constructor() {
}
windowChanged(_windowStartTime: number, _windowEndTime: number, _animate: boolean): void {
}
updateRangeSelection(_startTime: number, _endTime: number): void {
}
updateSelectedGroup(_flameChart: PerfUI.FlameChart.FlameChart, _group: PerfUI.FlameChart.Group|null): void {
}
}
class TickingFlameChartDataProvider implements PerfUI.FlameChart.FlameChartDataProvider {
private readonly updateMaxTimeHandle: (arg0: number) => void;
private bounds: Bounds;
private readonly liveEvents: Set<number>;
private eventMap: Map<number, Event>;
private readonly timelineDataInternal: PerfUI.FlameChart.FlameChartTimelineData;
private maxLevel: number;
constructor(initialBounds: Bounds, updateMaxTime: (arg0: number) => void) {
// do _not_ call this method from within this class - only for passing to events.
this.updateMaxTimeHandle = updateMaxTime;
this.bounds = initialBounds;
// All the events which should have their time updated when the chart ticks.
this.liveEvents = new Set();
// All events.
// Map<Event>
this.eventMap = new Map();
// Contains the numerical indicies. This is passed as a reference to the events
// so that they can update it when they change.
this.timelineDataInternal = PerfUI.FlameChart.FlameChartTimelineData.createEmpty();
// The current sum of all group heights.
this.maxLevel = 0;
}
/**
* Add a group with |name| that can contain |depth| different tracks.
*/
addGroup(name: Common.UIString.LocalizedString, depth: number): void {
if (this.timelineDataInternal.groups) {
const newGroup = {
name: name,
startLevel: this.maxLevel,
expanded: true,
selectable: false,
style: DefaultStyle(),
track: null,
};
this.timelineDataInternal.groups.push(newGroup);
ThemeSupport.ThemeSupport.instance().addEventListener(ThemeSupport.ThemeChangeEvent.eventName, () => {
newGroup.style.color = getGroupDefaultTextColor();
});
}
this.maxLevel += depth;
}
/**
* Create an event which will be set to live by default.
*/
startEvent(properties: EventProperties): Event {
properties['level'] = properties['level'] || 0;
if (properties['level'] > this.maxLevel) {
throw `level ${properties['level']} is above the maximum allowed of ${this.maxLevel}`;
}
const event = new Event(
this.timelineDataInternal, {
setLive: this.setLive.bind(this),
setComplete: this.setComplete.bind(this),
updateMaxTime: this.updateMaxTimeHandle,
},
properties);
this.eventMap.set(event.id, event);
return event;
}
private setLive(index: number): number {
this.liveEvents.add(index);
return this.bounds.max;
}
private setComplete(index: number): void {
this.liveEvents.delete(index);
}
updateMaxTime(bounds: Bounds): void {
this.bounds = bounds;
for (const eventID of this.liveEvents.entries()) {
// force recalculation of all live events.
(this.eventMap.get(eventID[0]) as Event).endTime = -1;
}
}
maxStackDepth(): number {
return this.maxLevel + 1;
}
timelineData(): PerfUI.FlameChart.FlameChartTimelineData {
return this.timelineDataInternal;
}
/** time in milliseconds
*/
minimumBoundary(): number {
return this.bounds.low;
}
totalTime(): number {
return this.bounds.high;
}
entryColor(index: number): string {
return (this.eventMap.get(index) as Event).color;
}
textColor(index: number): string {
return (this.eventMap.get(index) as Event).fontColor;
}
entryTitle(index: number): string|null {
return (this.eventMap.get(index) as Event).title;
}
entryFont(_index: number): string|null {
return defaultFont;
}
decorateEntry(
_index: number, _context: CanvasRenderingContext2D, _text: string|null, _barX: number, _barY: number,
_barWidth: number, _barHeight: number, _unclippedBarX: number, _timeToPixelRatio: number): boolean {
return false;
}
forceDecoration(_index: number): boolean {
return false;
}
prepareHighlightedEntryInfo(index: number): Element|null {
const element = document.createElement('div');
(this.eventMap.get(index) as Event).decorate(element);
return element;
}
formatValue(value: number, _precision?: number): string {
// value is always [0, X] so we need to add lower bound
value += Math.round(this.bounds.low);
// Magic numbers of pre-calculated logorithms.
// we want to show additional decimals at the time when two adjacent labels
// would otherwise show the same number. At 3840 pixels wide, that cutoff
// happens to be about 30 seconds for one decimal and 2.8 for two decimals.
if (this.bounds.range < 2800) {
return formatMillisecondsToSeconds(value, 2);
}
if (this.bounds.range < 30000) {
return formatMillisecondsToSeconds(value, 1);
}
return formatMillisecondsToSeconds(value, 0);
}
canJumpToEntry(_entryIndex: number): boolean {
return false;
}
navStartTimes(): Map<string, SDK.TracingModel.Event> {
return new Map();
}
}