Files
react-native-devtools-frontend/front_end/panels/timeline/CountersGraph.ts
T
Kateryna Prokopenko d1fcd6dbaf Use type-safe events for PerformanceModel
Bug: 1228674
Change-Id: I66cd36c680cda51eccdc83033d3f383f8fb9a528
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3103785
Reviewed-by: Simon Zünd <szuend@chromium.org>
Commit-Queue: Kateryna Prokopenko <kprokopenko@google.com>
2021-08-19 08:32:41 +00:00

608 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (C) 2012 Google Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
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 TimelineModel from '../../models/timeline_model/timeline_model.js';
import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js';
import * as UI from '../../ui/legacy/legacy.js';
import type {PerformanceModel, WindowChangedEvent} from './PerformanceModel.js';
import {Events} from './PerformanceModel.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}]',
};
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 model!: PerformanceModel|null;
private readonly header: UI.Widget.HBox;
readonly toolbar: UI.Toolbar.Toolbar;
private graphsContainer: UI.Widget.VBox;
canvasContainer: UI.Widget.WidgetElement;
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;
private track?: TimelineModel.TimelineModel.Track|null;
currentValuesBar?: HTMLElement;
private markerXPosition?: number;
constructor(delegate: TimelineModeViewDelegate) {
super();
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 = new UI.Toolbar.Toolbar('timeline-memory-toolbar');
this.header.element.appendChild(this.toolbar.element);
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';
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), 'hsl(220, 90%, 43%)', Platform.NumberUtilities.bytesToString));
this.countersByName.set('documents', this.createCounter(i18nString(UIStrings.documents), 'hsl(0, 90%, 43%)'));
this.countersByName.set('nodes', this.createCounter(i18nString(UIStrings.nodes), 'hsl(120, 90%, 43%)'));
this.countersByName.set(
'jsEventListeners', this.createCounter(i18nString(UIStrings.listeners), 'hsl(38, 90%, 43%)'));
this.gpuMemoryCounter = this.createCounter(
i18nString(UIStrings.gpuMemory), 'hsl(300, 90%, 43%)', Platform.NumberUtilities.bytesToString);
this.countersByName.set('gpuMemoryUsedKB', this.gpuMemoryCounter);
}
setModel(model: PerformanceModel|null, track: TimelineModel.TimelineModel.Track|null): void {
if (this.model !== model) {
if (this.model) {
this.model.removeEventListener(Events.WindowChanged, this.onWindowChanged, this);
}
this.model = model;
if (this.model) {
this.model.addEventListener(Events.WindowChanged, this.onWindowChanged, this);
}
}
this.calculator.setZeroTime(model ? model.timelineModel().minimumRecordTime() : 0);
for (let i = 0; i < this.counters.length; ++i) {
this.counters[i].reset();
this.counterUI[i].reset();
}
this.scheduleRefresh();
this.track = track;
if (!track) {
return;
}
const events = track.syncEvents();
for (let i = 0; i < events.length; ++i) {
const event = events[i];
if (event.name !== TimelineModel.TimelineModel.RecordType.UpdateCounters) {
continue;
}
const counters = event.args.data;
if (!counters) {
return;
}
for (const name in counters) {
const counter = this.countersByName.get(name);
if (counter) {
counter.appendSample(event.startTime, counters[name]);
}
}
const gpuMemoryLimitCounterName = 'gpuMemoryLimitKB';
if (gpuMemoryLimitCounterName in counters) {
this.gpuMemoryCounter.setLimit(counters[gpuMemoryLimitCounterName]);
}
}
}
private createCurrentValuesBar(): void {
this.currentValuesBar = this.graphsContainer.element.createChild('div');
this.currentValuesBar.id = 'counter-values-bar';
}
private createCounter(uiName: string, color: string, formatter?: ((arg0: number) => string)): Counter {
const counter = new Counter();
this.counters.push(counter);
this.counterUI.push(new CounterUI(this, uiName, color, counter, formatter));
return counter;
}
resizerElement(): Element|null {
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();
}
private onWindowChanged(event: Common.EventTarget.EventTargetEvent<WindowChangedEvent>): void {
const window = event.data.window;
this.calculator.setWindow(window.left, window.right);
this.scheduleRefresh();
}
scheduleRefresh(): void {
UI.UIUtils.invokeOnceAfterBatchUpdate(this, this.refresh);
}
draw(): void {
this.clear();
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: Event): void {
const x = (event as MouseEvent).x - this.canvasContainer.totalOffsetLeft();
let minDistance: number = 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.track) {
this.delegate.selectEntryAtTime(this.track.events.length ? this.track.events : this.track.asyncEvents, 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: Event): void {
const x = (event as MouseEvent).x - this.canvasContainer.totalOffsetLeft();
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;
private readonly formatter: (arg0: number) => string;
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly setting: Common.Settings.Setting<any>;
private filter: UI.Toolbar.ToolbarSettingCheckbox;
private range: HTMLElement;
private value: HTMLElement;
graphColor: string;
limitColor: string|null|undefined;
graphYValues: number[];
private readonly verticalPadding: number;
private currentValueLabel: string;
private marker: HTMLElement;
constructor(
countersPane: CountersGraph, title: string, graphColor: string, counter: Counter,
formatter?: (arg0: number) => string) {
this.countersPane = countersPane;
this.counter = counter;
this.formatter = formatter || Platform.NumberUtilities.withThousandsSeparator;
this.setting = Common.Settings.Settings.instance().createSetting('timelineCountersGraph-' + title, true);
this.setting.setTitle(title);
this.filter = new UI.Toolbar.ToolbarSettingCheckbox(this.setting, title);
this.filter.inputElement.classList.add('-theme-preserve-input');
const parsedColor = Common.Color.Color.parse(graphColor);
if (parsedColor) {
const colorWithAlpha = parsedColor.setAlpha(0.5).asString(Common.Color.Format.RGBA);
const htmlElement = (this.filter.element as HTMLElement);
if (colorWithAlpha) {
htmlElement.style.backgroundColor = colorWithAlpha;
}
htmlElement.style.borderColor = 'transparent';
}
this.filter.inputElement.addEventListener('click', this.toggleCounterGraph.bind(this));
countersPane.toolbar.appendToolbarItem(this.filter);
this.range = this.filter.element.createChild('span', 'range');
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.currentValueLabel = title;
this.marker = countersPane.canvasContainer.createChild('div', 'memory-counter-marker');
this.marker.style.backgroundColor = graphColor;
this.clearCurrentValueAndMarker();
}
reset(): void {
this.range.textContent = '';
}
setRange(minValue: number, maxValue: number): void {
const min = this.formatter(minValue);
const max = this.formatter(maxValue);
this.range.textContent = i18nString(UIStrings.ss, {PH1: min, PH2: max});
}
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 = Platform.NumberUtilities.withThousandsSeparator(this.counter.values[index]);
this.value.textContent = `${this.currentValueLabel}: ${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 PerfUI.TimelineGrid.Calculator {
private minimumBoundaryInternal: number;
private maximumBoundaryInternal: number;
private workingArea: number;
private zeroTimeInternal: number;
constructor() {
this.minimumBoundaryInternal = 0;
this.maximumBoundaryInternal = 0;
this.workingArea = 0;
this.zeroTimeInternal = 0;
}
setZeroTime(time: number): void {
this.zeroTimeInternal = time;
}
computePosition(time: number): number {
return (time - this.minimumBoundaryInternal) / this.boundarySpan() * this.workingArea;
}
setWindow(minimumBoundary: number, maximumBoundary: number): void {
this.minimumBoundaryInternal = minimumBoundary;
this.maximumBoundaryInternal = 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.maximumBoundaryInternal;
}
minimumBoundary(): number {
return this.minimumBoundaryInternal;
}
zeroTime(): number {
return this.zeroTimeInternal;
}
boundarySpan(): number {
return this.maximumBoundaryInternal - this.minimumBoundaryInternal;
}
}