refactor(devtools): refine and improve the new profiler layout (#62215)

Improve the styles and the components' layout in general.

PR Close #62215
This commit is contained in:
hawkgs
2025-06-20 13:33:20 +03:00
committed by Jessica Janiuk
parent 2788544c6a
commit 505da620ff
19 changed files with 265 additions and 170 deletions
@@ -19,7 +19,7 @@
<br />
<input
type="file"
class="ng-input"
class="ng-input size-mid"
(change)="importProfilerResults($event)"
placeholder="Upload file"
accept=".json"
@@ -29,5 +29,6 @@ ng_project(
"//:node_modules/@angular/material",
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording-timeline/record-formatter:record-formatter_rjs",
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/tab-update:tab-update_rjs",
"//devtools/projects/ng-devtools/src/lib/shared/button:button_rjs",
],
)
@@ -1,44 +1,46 @@
<mat-card class="bar-graph-container">
<p class="txt-frames" matTooltip="{{ selectionLabel() }} / {{ frameCount() }}">
{{ selectionLabel() }} / {{ frameCount() }}
</p>
<p class="selected-frame">{{ selectionLabel() }} / {{ frameCount() }}</p>
<button
mat-icon-button
(click)="move(-1)"
[disabled]="disablePreviousFrameButton()"
matTooltip="Select previous frame"
aria-label="Select previous frame"
>
<mat-icon>chevron_left</mat-icon>
</button>
<button
ng-button
btnType="icon"
(click)="move(-1)"
[disabled]="disablePreviousFrameButton()"
matTooltip="Select previous frame"
aria-label="Select previous frame"
>
<mat-icon>chevron_left</mat-icon>
</button>
<cdk-virtual-scroll-viewport
#barContainer
orientation="horizontal"
[itemSize]="itemWidth"
class="bar-container"
(mouseleave)="stopDragScrolling()"
(mousedown)="startDragScroll($event)"
(mouseup)="stopDragScrolling()"
(mousemove)="dragScroll($event)"
>
<div
*cdkVirtualFor="let d of graphData(); let i = index"
[ngStyle]="d.style"
class="frame-bar"
[class.selected]="selectedFrameIndexes().has(i)"
(click)="handleFrameSelection(i, $event)"
></div>
</cdk-virtual-scroll-viewport>
<cdk-virtual-scroll-viewport
#barContainer
orientation="horizontal"
[itemSize]="itemWidth"
class="bar-container"
(mouseleave)="stopDragScrolling()"
(mousedown)="startDragScroll($event)"
(mouseup)="stopDragScrolling()"
(mousemove)="dragScroll($event)"
[class.drag-scrolling]="dragScrolling()"
>
<div
*cdkVirtualFor="let d of graphData(); let i = index"
[ngStyle]="d.style"
class="frame-bar"
[class.selected]="selectedFrameIndexes().has(i)"
(click)="handleFrameSelection(i, $event)"
[matTooltip]="'Time spent: ' + (d.frame.duration | number) + 'ms\nSource: ' + d.frame.source"
[matTooltipShowDelay]="375"
matTooltipClass="multiline-tooltip"
></div>
</cdk-virtual-scroll-viewport>
<button
mat-icon-button
(click)="move(1)"
[disabled]="disableNextFrameButton()"
matTooltip="Select next frame"
aria-label="Select next frame"
>
<mat-icon>chevron_right</mat-icon>
</button>
</mat-card>
<button
ng-button
btnType="icon"
(click)="move(1)"
[disabled]="disableNextFrameButton()"
matTooltip="Select next frame"
aria-label="Select next frame"
>
<mat-icon>chevron_right</mat-icon>
</button>
@@ -1,64 +1,90 @@
@use '../../../../../styles/typography';
.bar-graph-container {
padding: 2px;
:host {
position: relative;
height: var(--max-bar-height);
flex: 0 0 var(--max-bar-height);
background-color: var(--octonary-contrast);
border-bottom: 1px solid var(--color-separator);
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: center;
align-items: center;
margin-bottom: 10px;
padding: 0.5rem;
gap: 0.375rem;
.txt-frames {
@extend %body-bold-01;
padding: 0px;
margin: 0px 10px;
width: 150px;
.selected-frame {
@extend %body-01;
position: absolute;
top: 0.5rem;
right: 2rem;
margin: 0;
padding: 0.1rem 0.375rem;
text-align: center;
background: var(--dynamic-transparent-01);
backdrop-filter: blur(10px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 0.75rem;
pointer-events: none;
z-index: 1;
}
.bar-container {
max-width: calc(100vw - 150px);
align-items: baseline;
overflow-x: auto;
width: 100%;
height: 100%;
flex: 1;
cursor: pointer;
::ng-deep .cdk-virtual-scroll-content-wrapper {
display: flex;
&.drag-scrolling {
/* We use an animation for the cursor to avoid
applying the style when we perform a regular click. */
animation: grabbing-cursor 1 forwards;
animation-delay: 200ms;
}
&::-webkit-scrollbar {
display: none;
}
.frame-bar {
margin-left: 2.5px;
margin-right: 2.5px;
margin-top: 2px;
::ng-deep .cdk-virtual-scroll-content-wrapper {
display: flex;
column-gap: 2.5px;
}
&:hover {
.frame-bar {
position: relative;
&::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
z-index: -1;
}
&:hover::before {
background-color: var(--septenary-contrast);
}
&.selected {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-left: 0.5px;
padding-right: 0.5px;
&.selected::before {
background-color: var(--dynamic-blue-01);
border: 2px solid var(--dynamic-blue-01);
}
}
}
}
button {
margin-bottom: 5px;
@keyframes grabbing-cursor {
from {
cursor: default;
}
to {
cursor: grabbing;
}
}
@@ -18,7 +18,9 @@ import {
ElementRef,
inject,
input,
linkedSignal,
output,
Signal,
signal,
viewChild,
} from '@angular/core';
@@ -28,13 +30,27 @@ import {TabUpdate} from '../../../tab-update/index';
import {GraphNode} from '../record-formatter/record-formatter';
import {MatIcon} from '@angular/material/icon';
import {MatTooltip} from '@angular/material/tooltip';
import {MatCard} from '@angular/material/card';
import {NgStyle} from '@angular/common';
import {MatIconButton} from '@angular/material/button';
import {DecimalPipe, NgStyle} from '@angular/common';
import {ProfilerFrame} from '../../../../../../../protocol';
import {ButtonComponent} from '../../../../shared/button/button.component';
const ITEM_WIDTH = 25;
const ITEM_GAP = 2.5;
const ITEM_WIDTH = 18;
const MAX_HEIGHT = 100;
const DRAG_SCROLL_SPEED = 2;
/** Returns a linked signal that resets on source change. */
function framesBoundSignal<T>(source: Signal<ProfilerFrame[]>, defaultValue: T) {
return linkedSignal<ProfilerFrame[], T>({
source,
computation: (source, prev) => {
if (source.length !== prev?.source.length) {
return defaultValue;
}
return prev.value;
},
});
}
@Component({
selector: 'ng-frame-selector',
@@ -44,14 +60,14 @@ const MAX_HEIGHT = 100;
:host { --max-bar-height: ${MAX_HEIGHT}px }
`,
imports: [
MatCard,
MatTooltip,
MatIcon,
MatIconButton,
CdkVirtualScrollViewport,
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
NgStyle,
ButtonComponent,
DecimalPipe,
],
})
export class FrameSelectorComponent {
@@ -64,9 +80,11 @@ export class FrameSelectorComponent {
readonly viewport = viewChild.required<CdkVirtualScrollViewport>(CdkVirtualScrollViewport);
readonly startFrameIndex = signal(-1);
readonly endFrameIndex = signal(-1);
readonly selectedFrameIndexes = signal(new Set<number>());
readonly startFrameIndex = framesBoundSignal<number>(this.frames, -1);
readonly endFrameIndex = framesBoundSignal<number>(this.frames, -1);
readonly selectedFrameIndexes = framesBoundSignal<Set<number>>(this.frames, new Set());
readonly dragScrolling = signal(false);
readonly frameCount = computed(() => this.frames().length);
readonly disableNextFrameButton = computed(
() => this.endFrameIndex() >= this.frameCount() - 1 || this.selectedFrameIndexes().size > 1,
@@ -84,7 +102,7 @@ export class FrameSelectorComponent {
private _viewportScrollState = {scrollLeft: 0, xCoordinate: 0, isDragScrolling: false};
readonly itemWidth = ITEM_WIDTH;
readonly itemWidth = ITEM_WIDTH + ITEM_GAP;
private readonly maxFrameDuration = computed(() =>
this.frames().reduce((acc: number, frame: ProfilerFrame) => Math.max(acc, frame.duration), 0),
@@ -212,10 +230,12 @@ export class FrameSelectorComponent {
}
stopDragScrolling(): void {
this.dragScrolling.set(false);
this._viewportScrollState.isDragScrolling = false;
}
startDragScroll(event: MouseEvent): void {
this.dragScrolling.set(true);
this._viewportScrollState = {
xCoordinate: event.clientX,
scrollLeft: this.viewport().elementRef.nativeElement.scrollLeft,
@@ -228,10 +248,9 @@ export class FrameSelectorComponent {
return;
}
const dragScrollSpeed = 2;
const dx = event.clientX - this._viewportScrollState.xCoordinate;
this.viewport().elementRef.nativeElement.scrollLeft =
this._viewportScrollState.scrollLeft - dx * dragScrollSpeed;
this._viewportScrollState.scrollLeft - dx * DRAG_SCROLL_SPEED;
}
private getBarStyles(frame: ProfilerFrame, multiplicationFactor: number): GraphNode {
@@ -243,9 +262,7 @@ export class FrameSelectorComponent {
const style = {
'background-image': `-webkit-linear-gradient(bottom, ${backgroundColor} ${colorPercentage}%, transparent ${colorPercentage}%)`,
cursor: 'pointer',
'min-width': '25px',
width: '25px',
width: ITEM_WIDTH + 'px',
height: MAX_HEIGHT + 'px',
};
const toolTip = `${frame.source} TimeSpent: ${frame.duration.toFixed(3)}ms`;
@@ -254,12 +271,12 @@ export class FrameSelectorComponent {
private getColorByFrameRate(framerate: number): string {
if (framerate >= 60) {
return '#d6f0d1';
} else if (framerate < 60 && framerate >= 30) {
return '#f2dca2';
} else if (framerate < 30 && framerate >= 15) {
return '#f9cc9d';
return 'var(--dynamic-green-01)';
} else if (60 > framerate && framerate >= 30) {
return 'var(--dynamic-yellow-01)';
} else if (30 > framerate && framerate >= 15) {
return 'var(--dynamic-red-03)';
}
return '#fad1d1';
return 'var(--dynamic-red-01)';
}
}
@@ -1,7 +1,10 @@
<input
matInput
class="ng-input filter-input"
class="ng-input size-mid filter-input"
(keyup)="filter.emit($any($event.target).value)"
placeholder="duration: >30 source: click"
(focus)="filterPlaceholder.set('duration: >30 source: click')"
(blur)="filterPlaceholder.set(FILTER_PLACEHOLDER)"
[placeholder]="filterPlaceholder()"
aria-label="Frames filter"
/>
<button ng-button (click)="exportProfile.emit()">Save Profile</button>
<button ng-button size="mid" (click)="exportProfile.emit()">Save Profile</button>
@@ -3,4 +3,8 @@
justify-content: space-between;
padding: 0.5rem;
border-bottom: 1px solid var(--color-separator);
input {
width: 10rem;
}
}
@@ -6,16 +6,21 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {Component, output} from '@angular/core';
import {ChangeDetectionStrategy, Component, output, signal} from '@angular/core';
import {ButtonComponent} from '../../../../shared/button/button.component';
const FILTER_PLACEHOLDER = 'Filter';
@Component({
selector: 'ng-recording-timeline-controls',
templateUrl: './recording-timeline-controls.component.html',
styleUrls: ['./recording-timeline-controls.component.scss'],
imports: [ButtonComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecordingTimelineControlsComponent {
readonly exportProfile = output<void>();
readonly filter = output<string>();
protected readonly exportProfile = output<void>();
protected readonly filter = output<string>();
protected readonly filterPlaceholder = signal(FILTER_PLACEHOLDER);
protected readonly FILTER_PLACEHOLDER = FILTER_PLACEHOLDER;
}
@@ -3,25 +3,28 @@
@if (!hasFrames() && !visualizing()) {
<ng-recording-modal />
}
@if (!hasFrames() && visualizing()) {
<p class="info">There's no information to show.</p>
}
@if (hasFrames()) {
<ng-recording-timeline-controls
(exportProfile)="exportProfile.emit($event)"
(filter)="setFilter($event)"
/>
<ng-frame-selector [frames]="frames()" (selectFrames)="selectFrames.set($event.indexes)" />
}
@if (hasFrames() && !frame) {
<p class="info">Select a bar to preview a particular change detection cycle.</p>
} @else if (hasFrames() && frame) {
<ng-visualizer-controls
[record]="frame!"
[record]="frame"
[estimatedFrameRate]="currentFrameRate()"
[(visualizationMode)]="visualizationMode"
[(changeDetection)]="changeDetection"
/>
}
@if (hasFrames() && !frame) {
<p class="info">Select a bar to preview a particular change detection cycle.</p>
} @else if (hasFrames() && frame) {
<ng-recording-visualizer
[visualizationMode]="visualizationMode()"
[frame]="frame"
@@ -4,7 +4,6 @@
display: block;
overflow: auto;
height: 100%;
padding-bottom: 1rem;
::ng-deep {
.as-split-gutter-icon {
@@ -22,7 +22,6 @@ ng_project(
"//packages/core",
],
deps = [
"//:node_modules/@angular/material",
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording-timeline/visualization-mode:visualization-mode_rjs",
"//devtools/projects/protocol:protocol_rjs",
],
@@ -1,43 +1,42 @@
<div
class="details"
[class.flame-details]="visualizationMode() === VisMode.FlameGraph"
[class.bar-details]="visualizationMode() === VisMode.BarGraph"
>
@if (estimatedFrameRate() >= 60 && record()) {
<label>
Time spent: <span class="value">{{ record()?.duration | number }} ms</span>
</label>
}
@if (estimatedFrameRate() < 60 && record()) {
<label class="warning-label">
Time spent: <span class="value">{{ record()?.duration | number }} ms</span>
</label>
}
@if (estimatedFrameRate() < 60 && record()) {
<label class="warning-label">
@let frameRecord = record();
<div class="details">
<p [class.warning-label]="estimatedFrameRate() < 60">
Time spent: <span class="value">{{ frameRecord.duration | number }} ms</span>
</p>
@if (estimatedFrameRate() < 60) {
<p class="warning-label">
Frame rate: <span class="value">{{ estimatedFrameRate() }} fps</span>
</label>
</p>
}
@if (record()?.source && record()) {
<label>
Source: <span class="value">{{ record()?.source }}</span>
</label>
}
@if (visualizationMode() === VisMode.FlameGraph) {
<mat-checkbox [checked]="changeDetection()" (change)="changeDetection.set($event.checked)">
Show only change detection
</mat-checkbox>
@if (frameRecord.source) {
<p>
Source: <span class="value">{{ frameRecord.source }}</span>
</p>
}
</div>
<select [hidden]="!record()" (change)="onVisualizationChange($event)" class="ng-select">
<option [value]="VisMode.FlameGraph" [selected]="visualizationMode() === VisMode.FlameGraph">
Flame graph
</option>
<option [value]="VisMode.TreeMap" [selected]="visualizationMode() === VisMode.TreeMap">
Tree map
</option>
<option [value]="VisMode.BarGraph" [selected]="visualizationMode() === VisMode.BarGraph">
Bar chart
</option>
</select>
<div class="controls">
@if (visualizationMode() === VisMode.FlameGraph) {
<input
id="cd-only-checkbox"
type="checkbox"
[checked]="changeDetection()"
(change)="changeDetection.set($any($event.target).checked)"
/>
<label for="cd-only-checkbox"> Show only change detection </label>
<div class="ver-ruler"></div>
}
<select #select (change)="onVisualizationChange(select.value)" class="ng-select size-mid">
<option [value]="VisMode.FlameGraph" [selected]="visualizationMode() === VisMode.FlameGraph">
Flame graph
</option>
<option [value]="VisMode.TreeMap" [selected]="visualizationMode() === VisMode.TreeMap">
Tree map
</option>
<option [value]="VisMode.BarGraph" [selected]="visualizationMode() === VisMode.BarGraph">
Bar chart
</option>
</select>
</div>
@@ -1,42 +1,44 @@
:host {
display: flex;
justify-content: space-between;
align-items: start;
padding: 0.5rem;
border-bottom: 1px solid var(--color-separator);
.details {
flex: 1;
display: flex;
flex-direction: column;
-moz-user-select: text;
-khtml-user-select: text;
-webkit-user-select: text;
-ms-user-select: text;
user-select: text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.bar-details {
min-height: 60px;
}
&.flame-details {
min-height: 85px;
}
label {
p {
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
&:not(:last-of-type) {
margin-bottom: 0.2rem;
}
&.warning-label {
color: var(--dynamic-red-01);
}
}
.value {
color: var(--dynamic-purple-01);
.value {
color: var(--dynamic-purple-01);
}
}
}
select {
display: block;
.controls {
display: flex;
align-items: center;
justify-content: space-between;
height: 24px;
#cd-only-checkbox {
margin-right: 0.5rem;
}
}
}
@@ -8,7 +8,6 @@
import {ChangeDetectionStrategy, Component, input, model} from '@angular/core';
import {DecimalPipe} from '@angular/common';
import {MatCheckbox} from '@angular/material/checkbox';
import {ProfilerFrame} from '../../../../../../../protocol';
import {VisualizationMode} from '../visualization-mode';
@@ -17,20 +16,20 @@ import {VisualizationMode} from '../visualization-mode';
selector: 'ng-visualizer-controls',
templateUrl: './visualizer-controls.component.html',
styleUrl: './visualizer-controls.component.scss',
imports: [MatCheckbox, DecimalPipe],
imports: [DecimalPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VisualizerControlsComponent {
readonly record = input<ProfilerFrame>();
readonly estimatedFrameRate = input.required<number>();
protected readonly record = input.required<ProfilerFrame>();
protected readonly estimatedFrameRate = input.required<number>();
readonly visualizationMode = model.required<VisualizationMode>();
readonly changeDetection = model.required<boolean>();
protected readonly visualizationMode = model.required<VisualizationMode>();
protected readonly changeDetection = model.required<boolean>();
VisMode = VisualizationMode;
protected readonly VisMode = VisualizationMode;
onVisualizationChange(e: Event) {
const selected = parseInt((e.target as HTMLSelectElement).value, 10);
protected onVisualizationChange(value: string) {
const selected = parseInt(value, 10);
this.visualizationMode.set(selected);
}
}
@@ -6,6 +6,10 @@
cursor: pointer;
padding: 0.375rem 0.75rem;
&.size-mid {
padding: 0.25rem 0.75rem;
}
&.size-compact {
padding: 0.1rem 0.5rem;
}
@@ -9,7 +9,7 @@
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
type ButtonType = 'primary' | 'icon';
type ButtonSize = 'standard' | 'compact';
type ButtonSize = 'standard' | 'mid' | 'compact';
@Component({
selector: 'button[ng-button]',
@@ -21,6 +21,7 @@ type ButtonSize = 'standard' | 'compact';
'[class.type-primary]': `btnType() === 'primary'`,
'[class.type-icon]': `btnType() === 'icon'`,
'[class.size-compact]': `size() === 'compact'`,
'[class.size-mid]': `size() === 'mid'`,
},
})
export class ButtonComponent {
@@ -31,6 +31,14 @@ $_colors: (
purple-02: oklch(57.9% 0.26 295),
purple-03: oklch(76% 0.15 305),
/* Yellows */
yellow-01: oklch(70% 0.14 81),
yellow-02: oklch(83% 0.14 81),
/* Oranges */
orange-01: oklch(69% 0.17 50),
orange-02: oklch(79% 0.17 50),
/* Grayscale */
gray-1000: oklch(16.93% 0 0),
gray-900: oklch(19.37% 0 0),
@@ -72,6 +80,8 @@ $_colors: (
--dynamic-blue-01: var(--blue-05);
--dynamic-blue-02: var(--blue-02);
--dynamic-purple-01: var(--purple-01);
--dynamic-yellow-01: var(--yellow-01);
--dynamic-orange-01: var(--orange-01);
--dynamic-transparent-01: var(--transparent-04);
--dynamic-transparent-02: var(--transparent-02);
@@ -114,6 +124,8 @@ $_colors: (
--dynamic-blue-01: var(--blue-01);
--dynamic-blue-02: var(--blue-03);
--dynamic-purple-01: var(--purple-03);
--dynamic-yellow-01: var(--yellow-02);
--dynamic-orange-01: var(--orange-02);
--dynamic-transparent-01: var(--transparent-03);
--dynamic-transparent-02: var(--transparent-01);
@@ -143,6 +143,17 @@ mat-form-field {
}
}
.multiline-tooltip {
white-space: pre-line;
}
[hidden] {
display: none !important;
}
.ver-ruler {
width: 1px;
height: 100%;
background-color: var(--color-separator);
margin-inline: 0.875rem;
}
@@ -18,6 +18,10 @@
color: var(--tertiary-contrast);
}
&[type='checkbox'] {
cursor: pointer;
}
&.size-mid {
padding: 0.25rem 0.75rem;
}
@@ -56,3 +60,7 @@
.ng-textarea {
border-radius: 0.75rem;
}
label[for] {
cursor: pointer;
}