chrome-devtools-frontend
Version:
Chrome DevTools UI
285 lines (242 loc) • 11.4 kB
text/typescript
// Copyright 2021 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 */
/*
* Copyright (C) 2007, 2008 Apple Inc. All rights reserved.
* Copyright (C) 2008, 2009 Anthony Ricaud <rik@webkit.org>
* Copyright (C) 2009 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:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. 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.
* 3. Neither the name of Apple Computer, Inc. ("Apple") 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 APPLE AND ITS 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 APPLE OR ITS 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 Platform from '../../../../core/platform/platform.js';
import * as ThemeSupport from '../../theme_support/theme_support.js';
import {DEFAULT_FONT_SIZE, getFontFamilyForCanvas} from './Font.js';
import timelineGridStyles from './timelineGrid.css.js';
const labelMap = new Map<HTMLDivElement|HTMLElement, HTMLDivElement>();
export class TimelineGrid {
element: HTMLDivElement;
private readonly dividersElementInternal: HTMLElement;
private readonly gridHeaderElement: HTMLDivElement;
private eventDividersElement: HTMLElement;
private dividersLabelBarElementInternal: HTMLElement;
constructor() {
this.element = document.createElement('div');
Platform.DOMUtilities.appendStyle(this.element, timelineGridStyles);
this.dividersElementInternal = this.element.createChild('div', 'resources-dividers');
this.gridHeaderElement = document.createElement('div');
this.gridHeaderElement.classList.add('timeline-grid-header');
this.eventDividersElement = this.gridHeaderElement.createChild('div', 'resources-event-dividers');
this.dividersLabelBarElementInternal = this.gridHeaderElement.createChild('div', 'resources-dividers-label-bar');
this.element.appendChild(this.gridHeaderElement);
}
static calculateGridOffsets(calculator: Calculator, freeZoneAtLeft?: number): DividersData {
const minGridSlicePx = 64; // minimal distance between grid lines.
const clientWidth = calculator.computePosition(calculator.maximumBoundary());
let dividersCount: number|0 = clientWidth / minGridSlicePx;
let gridSliceTime: number = calculator.boundarySpan() / dividersCount;
const pixelsPerTime = clientWidth / calculator.boundarySpan();
// Align gridSliceTime to a nearest round value.
// We allow spans that fit into the formula: span = (1|2|5)x10^n,
// e.g.: ... .1 .2 .5 1 2 5 10 20 50 ...
// After a span has been chosen make grid lines at multiples of the span.
const logGridSliceTime = Math.ceil(Math.log(gridSliceTime) / Math.LN10);
gridSliceTime = Math.pow(10, logGridSliceTime);
if (gridSliceTime * pixelsPerTime >= 5 * minGridSlicePx) {
gridSliceTime = gridSliceTime / 5;
}
if (gridSliceTime * pixelsPerTime >= 2 * minGridSlicePx) {
gridSliceTime = gridSliceTime / 2;
}
const firstDividerTime =
Math.ceil((calculator.minimumBoundary() - calculator.zeroTime()) / gridSliceTime) * gridSliceTime +
calculator.zeroTime();
let lastDividerTime = calculator.maximumBoundary();
// Add some extra space past the right boundary as the rightmost divider label text
// may be partially shown rather than just pop up when a new rightmost divider gets into the view.
lastDividerTime += minGridSlicePx / pixelsPerTime;
dividersCount = Math.ceil((lastDividerTime - firstDividerTime) / gridSliceTime);
if (!gridSliceTime) {
dividersCount = 0;
}
const offsets = [];
for (let i = 0; i < dividersCount; ++i) {
// The grid slice time could be small like 0.2. If we multiply this we
// open ourselves to floating point rounding errors. To avoid these, we
// multiply the number by 100, and i, and then divide it by 100 again.
const time = firstDividerTime + (gridSliceTime * 100 * i) / 100;
const positionFromTime = calculator.computePosition(time);
if (positionFromTime < (freeZoneAtLeft || 0)) {
continue;
}
offsets.push({position: Math.floor(positionFromTime), time});
}
return {offsets, precision: Math.max(0, -Math.floor(Math.log(gridSliceTime * 1.01) / Math.LN10))};
}
static drawCanvasGrid(context: CanvasRenderingContext2D, dividersData: DividersData): void {
context.save();
context.scale(window.devicePixelRatio, window.devicePixelRatio);
const height = Math.floor(context.canvas.height / window.devicePixelRatio);
context.strokeStyle = getComputedStyle(document.body).getPropertyValue('--app-color-strokestyle');
context.lineWidth = 1;
context.translate(0.5, 0.5);
context.beginPath();
for (const offsetInfo of dividersData.offsets) {
context.moveTo(offsetInfo.position, 0);
context.lineTo(offsetInfo.position, height);
}
context.stroke();
context.restore();
}
static drawCanvasHeaders(
context: CanvasRenderingContext2D, dividersData: DividersData, formatTimeFunction: (arg0: number) => string,
paddingTop: number, headerHeight: number, freeZoneAtLeft?: number): void {
context.save();
context.scale(window.devicePixelRatio, window.devicePixelRatio);
const width = Math.ceil(context.canvas.width / window.devicePixelRatio);
context.beginPath();
context.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-background-opacity-80');
context.fillRect(0, 0, width, headerHeight);
context.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-on-surface');
context.textBaseline = 'hanging';
context.font = `${DEFAULT_FONT_SIZE} ${getFontFamilyForCanvas()}`;
const paddingRight = 4;
for (const offsetInfo of dividersData.offsets) {
const text = formatTimeFunction(offsetInfo.time);
const textWidth = context.measureText(text).width;
const textPosition = offsetInfo.position - textWidth - paddingRight;
if (!freeZoneAtLeft || freeZoneAtLeft < textPosition) {
context.fillText(text, textPosition, paddingTop);
}
}
context.restore();
}
get dividersElement(): HTMLElement {
return this.dividersElementInternal;
}
get dividersLabelBarElement(): HTMLElement {
return this.dividersLabelBarElementInternal;
}
updateDividers(calculator: Calculator, freeZoneAtLeft?: number): boolean {
const dividersData = TimelineGrid.calculateGridOffsets(calculator, freeZoneAtLeft);
const dividerOffsets = dividersData.offsets;
const precision = dividersData.precision;
const dividersElementClientWidth = this.dividersElementInternal.clientWidth;
// Reuse divider elements and labels.
let divider = (this.dividersElementInternal.firstChild as HTMLElement | null);
let dividerLabelBar = (this.dividersLabelBarElementInternal.firstChild as HTMLElement | null);
for (let i = 0; i < dividerOffsets.length; ++i) {
if (!divider) {
divider = document.createElement('div');
divider.className = 'resources-divider';
this.dividersElementInternal.appendChild(divider);
dividerLabelBar = document.createElement('div');
dividerLabelBar.className = 'resources-divider';
const label = document.createElement('div');
label.className = 'resources-divider-label';
labelMap.set(dividerLabelBar, label);
dividerLabelBar.appendChild(label);
this.dividersLabelBarElementInternal.appendChild(dividerLabelBar);
}
const time = dividerOffsets[i].time;
const position = dividerOffsets[i].position;
if (dividerLabelBar) {
const label = labelMap.get(dividerLabelBar);
if (label) {
label.textContent = calculator.formatValue(time, precision);
}
}
const percentLeft = 100 * position / dividersElementClientWidth;
divider.style.left = percentLeft + '%';
if (dividerLabelBar) {
dividerLabelBar.style.left = percentLeft + '%';
}
divider = (divider.nextSibling as HTMLElement | null);
if (dividerLabelBar) {
dividerLabelBar = (dividerLabelBar.nextSibling as HTMLElement | null);
}
}
// Remove extras.
while (divider) {
const nextDivider = divider.nextSibling;
this.dividersElementInternal.removeChild(divider);
if (nextDivider) {
divider = (nextDivider as HTMLElement);
} else {
break;
}
}
while (dividerLabelBar) {
const nextDivider = dividerLabelBar.nextSibling;
this.dividersLabelBarElementInternal.removeChild(dividerLabelBar);
if (nextDivider) {
dividerLabelBar = (nextDivider as HTMLElement);
} else {
break;
}
}
return true;
}
addEventDivider(divider: Element): void {
this.eventDividersElement.appendChild(divider);
}
addEventDividers(dividers: Element[]): void {
this.gridHeaderElement.removeChild(this.eventDividersElement);
for (const divider of dividers) {
this.eventDividersElement.appendChild(divider);
}
this.gridHeaderElement.appendChild(this.eventDividersElement);
}
removeEventDividers(): void {
this.eventDividersElement.removeChildren();
}
hideEventDividers(): void {
this.eventDividersElement.classList.add('hidden');
}
showEventDividers(): void {
this.eventDividersElement.classList.remove('hidden');
}
setScrollTop(scrollTop: number): void {
this.dividersLabelBarElementInternal.style.top = scrollTop + 'px';
this.eventDividersElement.style.top = scrollTop + 'px';
}
}
// The TimelineGrid is used in the Performance panel and Memory panel -> Allocating sampling, so the value can be either
// milliseconds or bytes
export interface Calculator {
computePosition(value: number): number;
formatValue(value: number, precision?: number): string;
minimumBoundary(): number;
zeroTime(): number;
maximumBoundary(): number;
boundarySpan(): number;
}
export interface DividersData {
offsets: Array<{
position: number,
time: number,
}>;
precision: number;
}