| // 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. |
| |
| // @ts-nocheck |
| // TODO(crbug.com/1011811): Enable TypeScript compiler checks |
| |
| import * as Common from '../common/common.js'; |
| import * as Host from '../host/host.js'; |
| import * as PerfUI from '../perf_ui/perf_ui.js'; |
| import * as UI from '../ui/ui.js'; |
| |
| import {PlayerEvent} from './MediaModel.js'; // eslint-disable-line no-unused-vars |
| import {Bounds, FormatMillisecondsToSeconds} from './TickingFlameChartHelpers.js'; |
| |
| const defaultFont = '11px ' + Host.Platform.fontFamily(); |
| const defaultColor = '#444'; |
| |
| const DefaultStyle = { |
| height: 20, |
| padding: 2, |
| collapsible: false, |
| font: defaultFont, |
| color: defaultColor, |
| 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']; |
| |
| |
| /** |
| * @param {string} backgroundColor |
| * @return {string} |
| */ |
| function calculateFontColor(backgroundColor) { |
| // Dark background needs a light font. |
| if (Common.Color.Color.parse(backgroundColor).hsla()[2] < 0.5) { |
| return '#eee'; |
| } |
| return '#444'; |
| } |
| |
| |
| /** |
| * @typedef {{ |
| * setLive: (!function(number):number), |
| * setComplete: (!function(number):undefined), |
| * updateMaxTime: (!function(number):undefined) |
| * }} |
| */ |
| let EventHandlers; // eslint-disable-line no-unused-vars |
| |
| |
| /** |
| * @typedef {{ |
| * level: number, |
| * startTime: number, |
| * duration: (number|undefined), |
| * name: string, |
| * color: (string|undefined), |
| * hoverData: (Object|undefined|null) |
| * }} |
| */ |
| let EventProperties; // eslint-disable-line no-unused-vars |
| |
| /** |
| * Wrapper class for each event displayed on the timeline. |
| * @unrestricted |
| */ |
| export class Event { |
| constructor(timelineData, eventHandlers, eventProperties = {}) { |
| // 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._live = 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.push(eventProperties['level'] || 0); |
| this._timelineData.entryStartTimes.push(eventProperties['startTime'] || 0); |
| this._timelineData.entryTotalTimes.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._color = eventProperties['color'] || HotColorScheme[0]; |
| this._fontColor = calculateFontColor(this._color); |
| this._hoverData = eventProperties['hoverData'] || {}; |
| } |
| |
| /** |
| * Render hovertext into the |htmlElement| |
| * @param {!HTMLElement} htmlElement |
| */ |
| decorate(htmlElement) { |
| htmlElement.createChild('span').textContent = `Name: ${this._title}`; |
| htmlElement.createChild('br'); |
| |
| const startTimeReadable = FormatMillisecondsToSeconds(this.startTime, 2); |
| if (this._live) { |
| 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) { |
| // Setting end time to -1 signals that an event becomes live |
| if (time === -1) { |
| this._timelineData.entryTotalTimes[this._selfIndex] = this._setLive(this._selfIndex); |
| this._live = true; |
| } else { |
| this._live = false; |
| const duration = time - this._timelineData.entryStartTimes[this._selfIndex]; |
| this._timelineData.entryTotalTimes[this._selfIndex] = duration; |
| this._setComplete(this._selfIndex); |
| this._updateMaxTime(time); |
| } |
| } |
| |
| /** |
| * @return {number} |
| */ |
| get id() { |
| return this._selfIndex; |
| } |
| |
| /** |
| * @param {number} level |
| */ |
| set level(level) { |
| this._timelineData.entryLevels[this._selfIndex] = level; |
| } |
| |
| /** |
| * @param {string} text |
| */ |
| set title(text) { |
| this._title = text; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| get title() { |
| return this._title; |
| } |
| |
| /** |
| * @param {string} color |
| */ |
| set color(color) { |
| this._color = color; |
| this._fontColor = calculateFontColor(this._color); |
| } |
| |
| /** |
| * @return {string} |
| */ |
| get color() { |
| return this._color; |
| } |
| |
| get fontColor() { |
| return this._fontColor; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| get startTime() { |
| // Round it |
| return this._timelineData.entryStartTimes[this._selfIndex]; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| get duration() { |
| return this._timelineData.entryTotalTimes[this._selfIndex]; |
| } |
| |
| /** |
| * @return {boolean} |
| */ |
| get live() { |
| return this._live; |
| } |
| } |
| |
| |
| /** |
| * @unrestricted |
| */ |
| export class TickingFlameChart extends UI.Widget.VBox { |
| constructor() { |
| super(); |
| |
| // set to update once per second _while the tab is active_ |
| this._intervalTimer = null; |
| this._lastTimestamp = 0; |
| this._canTick = 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 = |
| 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', this._onScroll.bind(this)); |
| |
| // Add the chart. |
| this._chart.show(this.contentElement); |
| } |
| |
| /** |
| * Add a marker with |properties| at |time|. |
| * @param {!EventProperties} properties |
| */ |
| addMarker(properties) { |
| properties['duration'] = NaN; |
| this.startEvent(properties); |
| } |
| |
| /** |
| * Create an event which will be set to live by default. |
| * @param {!EventProperties} properties |
| * @return {!Event} |
| */ |
| startEvent(properties) { |
| // 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. |
| * @param {string} name |
| * @param {number} depth |
| */ |
| addGroup(name, depth) { |
| this._dataProvider.addGroup(name, depth); |
| } |
| |
| _updateMaxTime(time) { |
| if (this._bounds.pushMaxAtLeastTo(time)) { |
| this._updateRender(); |
| } |
| } |
| |
| _onScroll(e) { |
| // 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.clientWidth; |
| if (scrollTickCount > 0) { |
| this._bounds.zoomOut(scrollTickCount, scrollPositionRatio); |
| } else { |
| this._bounds.zoomIn(-scrollTickCount, scrollPositionRatio); |
| } |
| this._updateRender(); |
| } |
| |
| /** |
| * @override |
| */ |
| willHide() { |
| this._isShown = false; |
| if (this._ticking) { |
| this._stop(); |
| } |
| } |
| |
| /** |
| * @override |
| */ |
| wasShown() { |
| this._isShown = true; |
| if (this._canTick && !this._ticking) { |
| this._start(); |
| } |
| } |
| |
| /** |
| * Is the timeline allowed to tick forward. |
| * @param {boolean} allowed |
| */ |
| set canTick(allowed) { |
| this._canTick = allowed; |
| if (this._ticking && !allowed) { |
| this._stop(); |
| } |
| if (!this._ticking && this._isShown && allowed) { |
| this._start(); |
| } |
| } |
| |
| _start() { |
| if (this._lastTimestamp === 0) { |
| this._lastTimestamp = Date.now(); |
| } |
| if (this._intervalTimer !== null || this._stoppedPermanently) { |
| return; |
| } |
| // 16 ms is roughly 60 fps. |
| this._intervalTimer = setInterval(this._updateRender.bind(this), 16); |
| this._ticking = true; |
| } |
| |
| _stop(permanently = false) { |
| clearInterval(this._intervalTimer); |
| this._intervalTimer = null; |
| if (permanently) { |
| this._stoppedPermanently = true; |
| } |
| this._ticking = false; |
| } |
| |
| _updateRender() { |
| 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. |
| * @implements {PerfUI.FlameChart.FlameChartDelegate} |
| */ |
| class TickingFlameChartDelegate { |
| constructor() { |
| } |
| |
| /** |
| * @override |
| * @param {number} windowStartTime |
| * @param {number} windowEndTime |
| * @param {boolean} animate |
| */ |
| windowChanged(windowStartTime, windowEndTime, animate) { |
| } |
| |
| /** |
| * @override |
| * @param {number} startTime |
| * @param {number} endTime |
| */ |
| updateRangeSelection(startTime, endTime) { |
| } |
| |
| /** |
| * @override |
| * @param {!PerfUI.FlameChart.FlameChart} flameChart |
| * @param {?PerfUI.FlameChart.Group} group |
| */ |
| updateSelectedGroup(flameChart, group) { |
| } |
| } |
| |
| |
| /** |
| * @implements {PerfUI.FlameChart.FlameChartDataProvider} |
| */ |
| class TickingFlameChartDataProvider { |
| constructor(initialBounds, updateMaxTime) { |
| // 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._timelineData = new PerfUI.FlameChart.TimelineData([], [], [], []); |
| |
| // The current sum of all group heights. |
| this._maxLevel = 0; |
| } |
| |
| /** |
| * Add a group with |name| that can contain |depth| different tracks. |
| * @param {string} name |
| * @param {number} depth |
| */ |
| addGroup(name, depth) { |
| this._timelineData.groups.push( |
| {name: name, startLevel: this._maxLevel, expanded: true, selectable: false, style: DefaultStyle}); |
| this._maxLevel += depth; |
| } |
| |
| /** |
| * Create an event which will be set to live by default. |
| * @param {!EventProperties} properties |
| * @return {!Event} |
| */ |
| startEvent(properties) { |
| 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._timelineData, { |
| setLive: this._setLive.bind(this), |
| setComplete: this._setComplete.bind(this), |
| updateMaxTime: this._updateMaxTimeHandle |
| }, |
| properties); |
| |
| this._eventMap.set(event.id, event); |
| return event; |
| } |
| |
| /** |
| * @param {number} index |
| * @return {number} |
| */ |
| _setLive(index) { |
| this._liveEvents.add(index); |
| return this._bounds.max; |
| } |
| |
| /** |
| * @param {number} index |
| */ |
| _setComplete(index) { |
| this._liveEvents.delete(index); |
| } |
| |
| /** |
| * @param {!Bounds} bounds |
| */ |
| updateMaxTime(bounds) { |
| this._bounds = bounds; |
| for (const eventID of this._liveEvents.entries()) { |
| // force recalculation of all live events. |
| this._eventMap.get(eventID[0]).endTime = -1; |
| } |
| } |
| |
| |
| /** |
| * @override |
| * @return {number} |
| */ |
| maxStackDepth() { |
| return this._maxLevel + 1; |
| } |
| |
| /** |
| * @override |
| * @return {!PerfUI.FlameChart.TimelineData} |
| */ |
| timelineData() { |
| return this._timelineData; |
| } |
| |
| /** |
| * @override |
| * @return {number} time in milliseconds |
| */ |
| minimumBoundary() { |
| return this._bounds.low; |
| } |
| |
| /** |
| * @override |
| * @return {number} |
| */ |
| totalTime() { |
| return this._bounds.high; |
| } |
| |
| /** |
| * @override |
| * @param {number} index |
| * @return {string} |
| */ |
| entryColor(index) { |
| return this._eventMap.get(index).color; |
| } |
| |
| /** |
| * @override |
| * @param {number} index |
| * @return {string} |
| */ |
| textColor(index) { |
| return this._eventMap.get(index).fontColor; |
| } |
| |
| /** |
| * @override |
| * @param {number} index |
| * @return {?string} |
| */ |
| entryTitle(index) { |
| return this._eventMap.get(index).title; |
| } |
| |
| /** |
| * @override |
| * @param {number} index |
| * @return {?string} |
| */ |
| entryFont(index) { |
| return defaultFont; |
| } |
| |
| /** |
| * @override |
| * @param {number} index |
| * @param {!CanvasRenderingContext2D} context |
| * @param {?string} text |
| * @param {number} barX |
| * @param {number} barY |
| * @param {number} barWidth |
| * @param {number} barHeight |
| * @param {number} unclippedBarX |
| * @param {number} timeToPixelRatio |
| * @return {boolean} |
| */ |
| decorateEntry(index, context, text, barX, barY, barWidth, barHeight, unclippedBarX, timeToPixelRatio) { |
| return false; |
| } |
| |
| /** |
| * @override |
| * @param {number} index |
| * @return {boolean} |
| */ |
| forceDecoration(index) { |
| return false; |
| } |
| |
| /** |
| * @override |
| * @param {number} index |
| * @return {?Element} |
| */ |
| prepareHighlightedEntryInfo(index) { |
| const element = createElement('div'); |
| this._eventMap.get(index).decorate(element); |
| return element; |
| } |
| |
| /** |
| * @override |
| * @param {number} value |
| * @param {number=} precision |
| * @return {string} |
| */ |
| formatValue(value, precision) { |
| // 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); |
| } |
| |
| /** |
| * @override |
| * @param {number} entryIndex |
| * @return {boolean} |
| */ |
| canJumpToEntry(entryIndex) { |
| return false; |
| } |
| |
| /** |
| * @override |
| */ |
| navStartTimes() { |
| return new Map(); |
| } |
| } |