| // Copyright 2012 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /* eslint-disable @devtools/no-imperative-dom-api */ |
| |
| import '../../ui/legacy/legacy.js'; |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as i18n from '../../core/i18n/i18n.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| import * as Trace from '../../models/trace/trace.js'; |
| import * as TraceBounds from '../../services/trace_bounds/trace_bounds.js'; |
| import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| |
| import type {TimelineModeViewDelegate} from './TimelinePanel.js'; |
| |
| const UIStrings = { |
| /** |
| * @description Text for a heap profile type |
| */ |
| jsHeap: 'JS heap', |
| /** |
| * @description Text for documents, a type of resources |
| */ |
| documents: 'Documents', |
| /** |
| * @description Text in Counters Graph of the Performance panel |
| */ |
| nodes: 'Nodes', |
| /** |
| * @description Text in Counters Graph of the Performance panel |
| */ |
| listeners: 'Listeners', |
| /** |
| * @description Text in Counters Graph of the Performance panel |
| */ |
| gpuMemory: 'GPU memory', |
| /** |
| * @description Range text content in Counters Graph of the Performance panel |
| * @example {2} PH1 |
| * @example {10} PH2 |
| */ |
| ss: '[{PH1} – {PH2}]', |
| /** |
| * @description text shown when no counter events are found and the graph is empty |
| */ |
| noEventsFound: 'No memory usage data found within selected events.', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('panels/timeline/CountersGraph.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| export class CountersGraph extends UI.Widget.VBox { |
| private readonly delegate: TimelineModeViewDelegate; |
| private readonly calculator: Calculator; |
| private readonly header: UI.Widget.HBox; |
| readonly toolbar: UI.Toolbar.Toolbar; |
| private graphsContainer: UI.Widget.VBox; |
| canvasContainer: typeof UI.Widget.Widget.prototype.element; |
| private canvas: HTMLCanvasElement; |
| private readonly timelineGrid: PerfUI.TimelineGrid.TimelineGrid; |
| private readonly counters: Counter[]; |
| private readonly counterUI: CounterUI[]; |
| private readonly countersByName: Map<string, Counter>; |
| private readonly gpuMemoryCounter: Counter; |
| #events: Trace.Types.Events.Event[]|null = null; |
| currentValuesBar?: HTMLElement; |
| private markerXPosition?: number; |
| #onTraceBoundsChangeBound = this.#onTraceBoundsChange.bind(this); |
| |
| #noEventsFoundMessage = document.createElement('div'); |
| #showNoEventsMessage = false; |
| #defaultNumberFormatter: Intl.NumberFormat; |
| |
| constructor(delegate: TimelineModeViewDelegate) { |
| super(); |
| this.#defaultNumberFormatter = new Intl.NumberFormat( |
| i18n.DevToolsLocale.DevToolsLocale.instance().locale, |
| ); |
| |
| this.element.id = 'memory-graphs-container'; |
| |
| this.delegate = delegate; |
| this.calculator = new Calculator(); |
| |
| // Create selectors |
| this.header = new UI.Widget.HBox(); |
| this.header.element.classList.add('timeline-memory-header'); |
| this.header.show(this.element); |
| this.toolbar = this.header.element.createChild('devtools-toolbar', 'timeline-memory-toolbar'); |
| |
| this.graphsContainer = new UI.Widget.VBox(); |
| this.graphsContainer.show(this.element); |
| const canvasWidget = new UI.Widget.VBoxWithResizeCallback(this.resize.bind(this)); |
| canvasWidget.show(this.graphsContainer.element); |
| this.createCurrentValuesBar(); |
| this.canvasContainer = canvasWidget.element; |
| this.canvasContainer.id = 'memory-graphs-canvas-container'; |
| this.canvas = document.createElement('canvas'); |
| this.canvasContainer.appendChild(this.canvas); |
| this.canvas.id = 'memory-counters-graph'; |
| |
| const noEventsFound = document.createElement('p'); |
| noEventsFound.innerText = i18nString(UIStrings.noEventsFound); |
| this.#noEventsFoundMessage.classList.add('no-events-found'); |
| this.#noEventsFoundMessage.setAttribute('hidden', 'hidden'); |
| this.#noEventsFoundMessage.appendChild(noEventsFound); |
| this.canvasContainer.appendChild(this.#noEventsFoundMessage); |
| |
| this.canvasContainer.addEventListener('mouseover', this.onMouseMove.bind(this), true); |
| this.canvasContainer.addEventListener('mousemove', this.onMouseMove.bind(this), true); |
| this.canvasContainer.addEventListener('mouseleave', this.onMouseLeave.bind(this), true); |
| this.canvasContainer.addEventListener('click', this.onClick.bind(this), true); |
| // We create extra timeline grid here to reuse its event dividers. |
| this.timelineGrid = new PerfUI.TimelineGrid.TimelineGrid(); |
| this.canvasContainer.appendChild(this.timelineGrid.dividersElement); |
| |
| this.counters = []; |
| this.counterUI = []; |
| |
| this.countersByName = new Map(); |
| this.countersByName.set( |
| 'jsHeapSizeUsed', |
| this.createCounter( |
| i18nString(UIStrings.jsHeap), 'js-heap-size-used', 'hsl(220, 90%, 43%)', i18n.ByteUtilities.bytesToString)); |
| this.countersByName.set( |
| 'documents', this.createCounter(i18nString(UIStrings.documents), 'documents', 'hsl(0, 90%, 43%)')); |
| this.countersByName.set('nodes', this.createCounter(i18nString(UIStrings.nodes), 'nodes', 'hsl(120, 90%, 43%)')); |
| this.countersByName.set( |
| 'jsEventListeners', |
| this.createCounter(i18nString(UIStrings.listeners), 'js-event-listeners', 'hsl(38, 90%, 43%)')); |
| |
| this.gpuMemoryCounter = this.createCounter( |
| i18nString(UIStrings.gpuMemory), 'gpu-memory-used-kb', 'hsl(300, 90%, 43%)', i18n.ByteUtilities.bytesToString); |
| this.countersByName.set('gpuMemoryUsedKB', this.gpuMemoryCounter); |
| |
| TraceBounds.TraceBounds.onChange(this.#onTraceBoundsChangeBound); |
| } |
| |
| #onTraceBoundsChange(event: TraceBounds.TraceBounds.StateChangedEvent): void { |
| if (event.updateType === 'RESET' || event.updateType === 'VISIBLE_WINDOW') { |
| const newWindow = event.state.milli.timelineTraceWindow; |
| this.calculator.setWindow(newWindow.min, newWindow.max); |
| this.requestUpdate(); |
| } |
| } |
| |
| setModel(parsedTrace: Trace.TraceModel.ParsedTrace|null, events: Trace.Types.Events.Event[]|null): void { |
| this.#events = events; |
| if (!events || !parsedTrace) { |
| return; |
| } |
| const minTime = Trace.Helpers.Timing.traceWindowMilliSeconds(parsedTrace.data.Meta.traceBounds).min; |
| this.calculator.setZeroTime(minTime); |
| |
| for (let i = 0; i < this.counters.length; ++i) { |
| this.counters[i].reset(); |
| this.counterUI[i].reset(); |
| } |
| this.requestUpdate(); |
| let counterEventsFound = 0; |
| for (let i = 0; i < events.length; ++i) { |
| const event = events[i]; |
| if (!Trace.Types.Events.isUpdateCounters(event)) { |
| continue; |
| } |
| counterEventsFound++; |
| |
| const counters = event.args.data; |
| if (!counters) { |
| return; |
| } |
| for (const name in counters) { |
| const counter = this.countersByName.get(name); |
| if (counter) { |
| const {startTime} = Trace.Helpers.Timing.eventTimingsMilliSeconds(event); |
| counter.appendSample( |
| startTime, counters[name as 'documents' | 'jsEventListeners' | 'jsHeapSizeUsed' | 'nodes']); |
| } |
| } |
| |
| if (typeof counters.gpuMemoryLimitKB !== 'undefined') { |
| this.gpuMemoryCounter.setLimit(counters.gpuMemoryLimitKB); |
| } |
| } |
| this.#showNoEventsMessage = counterEventsFound === 0; |
| this.requestUpdate(); |
| } |
| |
| private createCurrentValuesBar(): void { |
| this.currentValuesBar = this.graphsContainer.element.createChild('div'); |
| this.currentValuesBar.id = 'counter-values-bar'; |
| } |
| |
| private createCounter( |
| uiName: Common.UIString.LocalizedString, settingsKey: string, color: string, |
| formatter?: ((arg0: number) => string)): Counter { |
| const counter = new Counter(); |
| this.counters.push(counter); |
| this.counterUI.push( |
| new CounterUI(this, uiName, settingsKey, color, counter, formatter ?? this.#defaultNumberFormatter.format)); |
| return counter; |
| } |
| |
| resizerElement(): Element { |
| return this.header.element; |
| } |
| |
| private resize(): void { |
| const parentElement = (this.canvas.parentElement as HTMLElement); |
| this.canvas.width = parentElement.clientWidth * window.devicePixelRatio; |
| this.canvas.height = parentElement.clientHeight * window.devicePixelRatio; |
| this.calculator.setDisplayWidth(this.canvas.width); |
| this.refresh(); |
| } |
| |
| override performUpdate(): Promise<void>|void { |
| this.refresh(); |
| } |
| |
| draw(): void { |
| this.clear(); |
| if (this.#showNoEventsMessage) { |
| this.#noEventsFoundMessage.removeAttribute('hidden'); |
| } else { |
| this.#noEventsFoundMessage.setAttribute('hidden', 'hidden'); |
| } |
| for (const counter of this.counters) { |
| counter.calculateVisibleIndexes(this.calculator); |
| counter.calculateXValues(this.canvas.width); |
| } |
| for (const counterUI of this.counterUI) { |
| counterUI.drawGraph(this.canvas); |
| } |
| } |
| |
| private onClick(event: MouseEvent): void { |
| const x = event.x - this.canvasContainer.getBoundingClientRect().left; |
| let minDistance = Infinity; |
| let bestTime; |
| for (const counterUI of this.counterUI) { |
| if (!counterUI.counter.times.length) { |
| continue; |
| } |
| const index = counterUI.recordIndexAt(x); |
| const distance = Math.abs(x * window.devicePixelRatio - counterUI.counter.x[index]); |
| if (distance < minDistance) { |
| minDistance = distance; |
| bestTime = counterUI.counter.times[index]; |
| } |
| } |
| if (bestTime !== undefined && this.#events) { |
| this.delegate.selectEntryAtTime(this.#events, bestTime); |
| } |
| } |
| |
| private onMouseLeave(_event: Event): void { |
| delete this.markerXPosition; |
| this.clearCurrentValueAndMarker(); |
| } |
| |
| private clearCurrentValueAndMarker(): void { |
| for (let i = 0; i < this.counterUI.length; i++) { |
| this.counterUI[i].clearCurrentValueAndMarker(); |
| } |
| } |
| |
| private onMouseMove(event: MouseEvent): void { |
| const x = event.x - this.canvasContainer.getBoundingClientRect().left; |
| this.markerXPosition = x; |
| this.refreshCurrentValues(); |
| } |
| |
| private refreshCurrentValues(): void { |
| if (this.markerXPosition === undefined) { |
| return; |
| } |
| for (let i = 0; i < this.counterUI.length; ++i) { |
| this.counterUI[i].updateCurrentValue(this.markerXPosition); |
| } |
| } |
| |
| refresh(): void { |
| this.timelineGrid.updateDividers(this.calculator); |
| this.draw(); |
| this.refreshCurrentValues(); |
| } |
| |
| private clear(): void { |
| const ctx = this.canvas.getContext('2d'); |
| if (!ctx) { |
| throw new Error('Unable to get canvas context'); |
| } |
| ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); |
| } |
| } |
| |
| export class Counter { |
| times: number[]; |
| values: number[]; |
| x: number[]; |
| minimumIndex: number; |
| maximumIndex: number; |
| private maxTime: number; |
| private minTime: number; |
| limitValue?: number; |
| |
| constructor() { |
| this.times = []; |
| this.values = []; |
| this.x = []; |
| this.minimumIndex = 0; |
| this.maximumIndex = 0; |
| this.maxTime = 0; |
| this.minTime = 0; |
| } |
| |
| appendSample(time: number, value: number): void { |
| if (this.values.length && this.values[this.values.length - 1] === value) { |
| return; |
| } |
| this.times.push(time); |
| this.values.push(value); |
| } |
| |
| reset(): void { |
| this.times = []; |
| this.values = []; |
| } |
| |
| setLimit(value: number): void { |
| this.limitValue = value; |
| } |
| |
| calculateBounds(): { |
| min: number, |
| max: number, |
| } { |
| let maxValue; |
| let minValue; |
| for (let i = this.minimumIndex; i <= this.maximumIndex; i++) { |
| const value = this.values[i]; |
| if (minValue === undefined || value < minValue) { |
| minValue = value; |
| } |
| if (maxValue === undefined || value > maxValue) { |
| maxValue = value; |
| } |
| } |
| minValue = minValue || 0; |
| maxValue = maxValue || 1; |
| if (this.limitValue) { |
| if (maxValue > this.limitValue * 0.5) { |
| maxValue = Math.max(maxValue, this.limitValue); |
| } |
| minValue = Math.min(minValue, this.limitValue); |
| } |
| return {min: minValue, max: maxValue}; |
| } |
| |
| calculateVisibleIndexes(calculator: Calculator): void { |
| const start = calculator.minimumBoundary(); |
| const end = calculator.maximumBoundary(); |
| |
| // Maximum index of element whose time <= start. |
| this.minimumIndex = Platform.NumberUtilities.clamp( |
| Platform.ArrayUtilities.upperBound(this.times, start, Platform.ArrayUtilities.DEFAULT_COMPARATOR) - 1, 0, |
| this.times.length - 1); |
| |
| // Minimum index of element whose time >= end. |
| this.maximumIndex = Platform.NumberUtilities.clamp( |
| Platform.ArrayUtilities.lowerBound(this.times, end, Platform.ArrayUtilities.DEFAULT_COMPARATOR), 0, |
| this.times.length - 1); |
| |
| // Current window bounds. |
| this.minTime = start; |
| this.maxTime = end; |
| } |
| |
| calculateXValues(width: number): void { |
| if (!this.values.length) { |
| return; |
| } |
| |
| const xFactor = width / (this.maxTime - this.minTime); |
| |
| this.x = new Array(this.values.length); |
| for (let i = this.minimumIndex + 1; i <= this.maximumIndex; i++) { |
| this.x[i] = xFactor * (this.times[i] - this.minTime); |
| } |
| } |
| } |
| |
| export class CounterUI { |
| private readonly countersPane: CountersGraph; |
| counter: Counter; |
| readonly formatter: (arg0: number) => string; |
| private readonly setting: Common.Settings.Setting<boolean>; |
| private readonly filter: UI.Toolbar.ToolbarSettingCheckbox; |
| private readonly value: HTMLElement; |
| graphColor: string; |
| limitColor: string|null|undefined; |
| graphYValues: number[]; |
| private readonly verticalPadding: number; |
| private readonly counterName: Common.UIString.LocalizedString; |
| private readonly marker: HTMLElement; |
| |
| constructor( |
| countersPane: CountersGraph, title: Common.UIString.LocalizedString, settingsKey: string, graphColor: string, |
| counter: Counter, formatter: (arg0: number) => string) { |
| this.countersPane = countersPane; |
| this.counter = counter; |
| this.formatter = formatter; |
| |
| this.setting = Common.Settings.Settings.instance().createSetting('timeline-counters-graph-' + settingsKey, true); |
| this.setting.setTitle(title); |
| this.filter = new UI.Toolbar.ToolbarSettingCheckbox(this.setting, title); |
| const parsedColor = Common.Color.parse(graphColor); |
| if (parsedColor) { |
| const colorWithAlpha = parsedColor.setAlpha(0.5).asString(Common.Color.Format.RGBA); |
| const htmlElement = (this.filter.element); |
| if (colorWithAlpha) { |
| htmlElement.style.backgroundColor = colorWithAlpha; |
| } |
| htmlElement.style.borderColor = 'transparent'; |
| } |
| this.filter.element.addEventListener('click', this.toggleCounterGraph.bind(this)); |
| countersPane.toolbar.appendToolbarItem(this.filter); |
| |
| this.value = (countersPane.currentValuesBar as HTMLElement).createChild('span', 'memory-counter-value'); |
| this.value.style.color = graphColor; |
| this.graphColor = graphColor; |
| if (parsedColor) { |
| this.limitColor = parsedColor.setAlpha(0.3).asString(Common.Color.Format.RGBA); |
| } |
| this.graphYValues = []; |
| this.verticalPadding = 10; |
| |
| this.counterName = title; |
| this.marker = countersPane.canvasContainer.createChild('div', 'memory-counter-marker'); |
| this.marker.style.backgroundColor = graphColor; |
| this.clearCurrentValueAndMarker(); |
| } |
| |
| /** |
| * Updates both the user visible text and the title & aria-label for the |
| * checkbox label shown in the toolbar |
| */ |
| #updateFilterLabel(text: Common.UIString.LocalizedString): void { |
| this.filter.setLabelText(text); |
| this.filter.setTitle(text); |
| } |
| |
| reset(): void { |
| this.#updateFilterLabel(this.counterName); |
| } |
| |
| setRange(minValue: number, maxValue: number): void { |
| const min = this.formatter(minValue); |
| const max = this.formatter(maxValue); |
| const rangeText = i18nString(UIStrings.ss, {PH1: min, PH2: max}); |
| const newLabelText = `${this.counterName} ${rangeText}` as Common.UIString.LocalizedString; |
| this.#updateFilterLabel(newLabelText); |
| } |
| |
| private toggleCounterGraph(): void { |
| this.value.classList.toggle('hidden', !this.filter.checked()); |
| this.countersPane.refresh(); |
| } |
| |
| recordIndexAt(x: number): number { |
| return Platform.ArrayUtilities.upperBound( |
| this.counter.x, x * window.devicePixelRatio, Platform.ArrayUtilities.DEFAULT_COMPARATOR, |
| this.counter.minimumIndex + 1, this.counter.maximumIndex + 1) - |
| 1; |
| } |
| |
| updateCurrentValue(x: number): void { |
| if (!this.visible() || !this.counter.values.length || !this.counter.x) { |
| return; |
| } |
| const index = this.recordIndexAt(x); |
| const value = this.formatter(this.counter.values[index]); |
| this.value.textContent = `${this.counterName}: ${value}`; |
| const y = this.graphYValues[index] / window.devicePixelRatio; |
| this.marker.style.left = x + 'px'; |
| this.marker.style.top = y + 'px'; |
| this.marker.classList.remove('hidden'); |
| } |
| |
| clearCurrentValueAndMarker(): void { |
| this.value.textContent = ''; |
| this.marker.classList.add('hidden'); |
| } |
| |
| drawGraph(canvas: HTMLCanvasElement): void { |
| const ctx = canvas.getContext('2d'); |
| if (!ctx) { |
| throw new Error('Unable to get canvas context'); |
| } |
| const width = canvas.width; |
| const height = canvas.height - 2 * this.verticalPadding; |
| if (height <= 0) { |
| this.graphYValues = []; |
| return; |
| } |
| const originY = this.verticalPadding; |
| const counter = this.counter; |
| const values = counter.values; |
| |
| if (!values.length) { |
| return; |
| } |
| |
| const bounds = counter.calculateBounds(); |
| const minValue = bounds.min; |
| const maxValue = bounds.max; |
| this.setRange(minValue, maxValue); |
| |
| if (!this.visible()) { |
| return; |
| } |
| |
| const yValues = this.graphYValues; |
| const maxYRange = maxValue - minValue; |
| const yFactor = maxYRange ? height / (maxYRange) : 1; |
| |
| ctx.save(); |
| ctx.lineWidth = window.devicePixelRatio; |
| if (ctx.lineWidth % 2) { |
| ctx.translate(0.5, 0.5); |
| } |
| ctx.beginPath(); |
| let value: number = values[counter.minimumIndex]; |
| let currentY = Math.round(originY + height - (value - minValue) * yFactor); |
| ctx.moveTo(0, currentY); |
| let i = counter.minimumIndex; |
| for (; i <= counter.maximumIndex; i++) { |
| const x = Math.round(counter.x[i]); |
| ctx.lineTo(x, currentY); |
| const currentValue = values[i]; |
| if (typeof currentValue !== 'undefined') { |
| value = currentValue; |
| } |
| currentY = Math.round(originY + height - (value - minValue) * yFactor); |
| ctx.lineTo(x, currentY); |
| yValues[i] = currentY; |
| } |
| yValues.length = i; |
| ctx.lineTo(width, currentY); |
| ctx.strokeStyle = this.graphColor; |
| ctx.stroke(); |
| if (counter.limitValue) { |
| const limitLineY = Math.round(originY + height - (counter.limitValue - minValue) * yFactor); |
| ctx.moveTo(0, limitLineY); |
| ctx.lineTo(width, limitLineY); |
| if (this.limitColor) { |
| ctx.strokeStyle = this.limitColor; |
| } |
| ctx.stroke(); |
| } |
| ctx.closePath(); |
| ctx.restore(); |
| } |
| |
| visible(): boolean { |
| return this.filter.checked(); |
| } |
| } |
| |
| export class Calculator implements Calculator { |
| #minimumBoundary: number; |
| #maximumBoundary: number; |
| private workingArea: number; |
| #zeroTime: number; |
| |
| constructor() { |
| this.#minimumBoundary = 0; |
| this.#maximumBoundary = 0; |
| this.workingArea = 0; |
| this.#zeroTime = 0; |
| } |
| setZeroTime(time: number): void { |
| this.#zeroTime = time; |
| } |
| |
| computePosition(time: number): number { |
| return (time - this.#minimumBoundary) / this.boundarySpan() * this.workingArea; |
| } |
| |
| setWindow(minimumBoundary: number, maximumBoundary: number): void { |
| this.#minimumBoundary = minimumBoundary; |
| this.#maximumBoundary = maximumBoundary; |
| } |
| |
| setDisplayWidth(clientWidth: number): void { |
| this.workingArea = clientWidth; |
| } |
| |
| formatValue(value: number, precision?: number): string { |
| return i18n.TimeUtilities.preciseMillisToString(value - this.zeroTime(), precision); |
| } |
| |
| maximumBoundary(): number { |
| return this.#maximumBoundary; |
| } |
| |
| minimumBoundary(): number { |
| return this.#minimumBoundary; |
| } |
| |
| zeroTime(): number { |
| return this.#zeroTime; |
| } |
| |
| boundarySpan(): number { |
| return this.#maximumBoundary - this.#minimumBoundary; |
| } |
| } |