chrome-devtools-frontend
Version:
Chrome DevTools UI
355 lines (311 loc) • 12 kB
text/typescript
// Copyright 2018 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.
/* eslint-disable rulesdir/no-imperative-dom-api */
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 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 * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
export class HeapTimelineOverview extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.VBox>(
UI.Widget.VBox) {
readonly overviewCalculator: OverviewCalculator;
overviewContainer: HTMLElement;
overviewGrid: PerfUI.OverviewGrid.OverviewGrid;
overviewCanvas: HTMLCanvasElement;
windowLeftRatio: number;
windowRightRatio: number;
readonly yScale: SmoothScale;
readonly xScale: SmoothScale;
profileSamples: Samples;
running?: boolean;
updateOverviewCanvas?: boolean;
updateGridTimerId?: number;
updateTimerId?: number|null;
windowWidthRatio?: number;
constructor() {
super();
this.element.id = 'heap-recording-view';
this.element.classList.add('heap-tracking-overview');
this.element.setAttribute('jslog', `${VisualLogging.section('heap-tracking-overview')}`);
this.overviewCalculator = new OverviewCalculator();
this.overviewContainer = this.element.createChild('div', 'heap-overview-container');
this.overviewGrid = new PerfUI.OverviewGrid.OverviewGrid('heap-recording', this.overviewCalculator);
this.overviewGrid.element.classList.add('fill');
this.overviewCanvas = this.overviewContainer.createChild('canvas', 'heap-recording-overview-canvas');
this.overviewContainer.appendChild(this.overviewGrid.element);
this.overviewGrid.addEventListener(PerfUI.OverviewGrid.Events.WINDOW_CHANGED, this.onWindowChanged, this);
this.windowLeftRatio = 0.0;
this.windowRightRatio = 1.0;
this.overviewGrid.setWindowRatio(this.windowLeftRatio, this.windowRightRatio);
this.yScale = new SmoothScale();
this.xScale = new SmoothScale();
this.profileSamples = new Samples();
ThemeSupport.ThemeSupport.instance().addEventListener(ThemeSupport.ThemeChangeEvent.eventName, () => this.update());
}
start(): void {
this.running = true;
const drawFrame = (): void => {
this.update();
if (this.running) {
this.element.window().requestAnimationFrame(drawFrame);
}
};
drawFrame();
}
stop(): void {
this.running = false;
}
setSamples(samples: Samples): void {
this.profileSamples = samples;
if (!this.running) {
this.update();
}
}
drawOverviewCanvas(width: number, height: number): void {
if (!this.profileSamples) {
return;
}
const profileSamples = this.profileSamples;
const sizes = profileSamples.sizes;
const topSizes = profileSamples.max;
const timestamps = profileSamples.timestamps;
const startTime = timestamps[0];
const scaleFactor = this.xScale.nextScale(width / profileSamples.totalTime);
let maxSize = 0;
function aggregateAndCall(sizes: number[], callback: (arg0: number, arg1: number) => void): void {
let size = 0;
let currentX = 0;
for (let i = 1; i < timestamps.length; ++i) {
const x = Math.floor((timestamps[i] - startTime) * scaleFactor);
if (x !== currentX) {
if (size) {
callback(currentX, size);
}
size = 0;
currentX = x;
}
size += sizes[i];
}
callback(currentX, size);
}
function maxSizeCallback(_x: number, size: number): void {
maxSize = Math.max(maxSize, size);
}
aggregateAndCall(sizes, maxSizeCallback);
const yScaleFactor = this.yScale.nextScale(maxSize ? height / (maxSize * 1.1) : 0.0);
this.overviewCanvas.width = width * window.devicePixelRatio;
this.overviewCanvas.height = height * window.devicePixelRatio;
this.overviewCanvas.style.width = width + 'px';
this.overviewCanvas.style.height = height + 'px';
const maybeContext = this.overviewCanvas.getContext('2d');
if (!maybeContext) {
throw new Error('Failed to get canvas context');
}
const context = maybeContext;
context.scale(window.devicePixelRatio, window.devicePixelRatio);
if (this.running) {
context.beginPath();
context.lineWidth = 2;
context.strokeStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-neutral-outline');
const currentX = (Date.now() - startTime) * scaleFactor;
context.moveTo(currentX, height - 1);
context.lineTo(currentX, 0);
context.stroke();
context.closePath();
}
let gridY = 0;
let gridValue;
const gridLabelHeight = 14;
if (yScaleFactor) {
const maxGridValue = (height - gridLabelHeight) / yScaleFactor;
// The round value calculation is a bit tricky, because
// it has a form k*10^n*1024^m, where k=[1,5], n=[0..3], m is an integer,
// e.g. a round value 10KB is 10240 bytes.
gridValue = Math.pow(1024, Math.floor(Math.log(maxGridValue) / Math.log(1024)));
gridValue *= Math.pow(10, Math.floor(Math.log(maxGridValue / gridValue) / Math.LN10));
if (gridValue * 5 <= maxGridValue) {
gridValue *= 5;
}
gridY = Math.round(height - gridValue * yScaleFactor - 0.5) + 0.5;
context.beginPath();
context.lineWidth = 1;
context.strokeStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-on-surface-subtle');
context.moveTo(0, gridY);
context.lineTo(width, gridY);
context.stroke();
context.closePath();
}
function drawBarCallback(x: number, size: number): void {
context.moveTo(x, height - 1);
context.lineTo(x, Math.round(height - size * yScaleFactor - 1));
}
context.beginPath();
context.lineWidth = 2;
context.strokeStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-neutral-outline');
aggregateAndCall(topSizes, drawBarCallback);
context.stroke();
context.closePath();
context.beginPath();
context.lineWidth = 2;
context.strokeStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-primary-bright');
aggregateAndCall(sizes, drawBarCallback);
context.stroke();
context.closePath();
if (gridValue) {
const label = i18n.ByteUtilities.bytesToString(gridValue);
const labelPadding = 4;
const labelX = 0;
const labelY = gridY - 0.5;
const labelWidth = 2 * labelPadding + context.measureText(label).width;
context.beginPath();
context.textBaseline = 'bottom';
context.font = '10px ' + window.getComputedStyle(this.element, null).getPropertyValue('font-family');
// Background behind text for better contrast. Some opacity so canvas can still bleed through
context.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-background-opacity-80');
context.fillRect(labelX, labelY - gridLabelHeight, labelWidth, gridLabelHeight);
context.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-on-surface-subtle');
context.fillText(label, labelX + labelPadding, labelY);
context.fill();
context.closePath();
}
}
override onResize(): void {
this.updateOverviewCanvas = true;
this.scheduleUpdate();
}
onWindowChanged(): void {
if (!this.updateGridTimerId) {
this.updateGridTimerId = window.setTimeout(this.updateGrid.bind(this), 10);
}
}
scheduleUpdate(): void {
if (this.updateTimerId) {
return;
}
this.updateTimerId = window.setTimeout(this.update.bind(this), 10);
}
updateBoundaries(): void {
this.windowLeftRatio = this.overviewGrid.windowLeftRatio();
this.windowRightRatio = this.overviewGrid.windowRightRatio();
this.windowWidthRatio = this.windowRightRatio - this.windowLeftRatio;
}
update(): void {
this.updateTimerId = null;
if (!this.isShowing()) {
return;
}
this.updateBoundaries();
this.overviewCalculator.updateBoundaries(this);
this.overviewGrid.updateDividers(this.overviewCalculator);
this.drawOverviewCanvas(this.overviewContainer.clientWidth, this.overviewContainer.clientHeight - 20);
}
updateGrid(): void {
this.updateGridTimerId = 0;
this.updateBoundaries();
const ids = this.profileSamples.ids;
if (!ids.length) {
return;
}
const timestamps = this.profileSamples.timestamps;
const sizes = this.profileSamples.sizes;
const startTime = timestamps[0];
const totalTime = this.profileSamples.totalTime;
const timeLeft = startTime + totalTime * this.windowLeftRatio;
const timeRight = startTime + totalTime * this.windowRightRatio;
const minIndex =
Platform.ArrayUtilities.lowerBound(timestamps, timeLeft, Platform.ArrayUtilities.DEFAULT_COMPARATOR);
const maxIndex =
Platform.ArrayUtilities.upperBound(timestamps, timeRight, Platform.ArrayUtilities.DEFAULT_COMPARATOR);
let size = 0;
for (let i = minIndex; i < maxIndex; ++i) {
size += sizes[i];
}
const minId = minIndex > 0 ? ids[minIndex - 1] : 0;
const maxId = maxIndex < ids.length ? ids[maxIndex] : Infinity;
this.dispatchEventToListeners(Events.IDS_RANGE_CHANGED, {minId, maxId, size});
}
}
export const enum Events {
IDS_RANGE_CHANGED = 'IdsRangeChanged',
}
export interface IdsRangeChangedEvent {
minId: number;
maxId: number;
size: number;
}
export interface EventTypes {
[Events.IDS_RANGE_CHANGED]: IdsRangeChangedEvent;
}
export class SmoothScale {
lastUpdate: number;
currentScale: number;
constructor() {
this.lastUpdate = 0;
this.currentScale = 0.0;
}
nextScale(target: number): number {
target = target || this.currentScale;
if (this.currentScale) {
const now = Date.now();
const timeDeltaMs = now - this.lastUpdate;
this.lastUpdate = now;
const maxChangePerSec = 20;
const maxChangePerDelta = Math.pow(maxChangePerSec, timeDeltaMs / 1000);
const scaleChange = target / this.currentScale;
this.currentScale *= Platform.NumberUtilities.clamp(scaleChange, 1 / maxChangePerDelta, maxChangePerDelta);
} else {
this.currentScale = target;
}
return this.currentScale;
}
}
export class Samples {
sizes: number[];
ids: number[];
timestamps: number[];
max: number[];
totalTime: number;
constructor() {
this.sizes = [];
this.ids = [];
this.timestamps = [];
this.max = [];
this.totalTime = 30000;
}
}
export class OverviewCalculator implements PerfUI.TimelineGrid.Calculator {
maximumBoundaries: number;
minimumBoundaries: number;
xScaleFactor: number;
constructor() {
this.maximumBoundaries = 0;
this.minimumBoundaries = 0;
this.xScaleFactor = 0;
}
updateBoundaries(chart: HeapTimelineOverview): void {
this.minimumBoundaries = 0;
this.maximumBoundaries = chart.profileSamples.totalTime;
this.xScaleFactor = chart.overviewContainer.clientWidth / this.maximumBoundaries;
}
computePosition(time: number): number {
return (time - this.minimumBoundaries) * this.xScaleFactor;
}
formatValue(value: number, precision?: number): string {
return i18n.TimeUtilities.secondsToString(value / 1000, Boolean(precision));
}
maximumBoundary(): number {
return this.maximumBoundaries;
}
minimumBoundary(): number {
return this.minimumBoundaries;
}
zeroTime(): number {
return this.minimumBoundaries;
}
boundarySpan(): number {
return this.maximumBoundaries - this.minimumBoundaries;
}
}