feat(devtools): implement time travel player functionality (rangle/angular-devtools#46)

This commit is contained in:
AleksanderBodurri 2020-02-07 13:38:03 -05:00 committed by GitHub
parent 059ef511d0
commit 07cd82a10c
3 changed files with 160 additions and 49 deletions

View file

@ -6,20 +6,35 @@
}
.controls {
position: absolute;
top: 0px;
left: 340px
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin: auto;
width: 85%;
}
/deep/ .vis-network {
outline: none;
.controls .control-buttons {
width: 35%;
}
.controls .control-buttons button {
}
.timestamp {
font-weight: 300;
position: absolute;
top: 25px;
left: 340px;
}
.controls .status {
justify-content: space-between;
display: flex;
align-items: center;
width: 45%;
}
/deep/ .vis-network {
outline: none;
}
.side-panel {

View file

@ -1,11 +1,38 @@
<div class="controls">
Timeline:&nbsp;
<mat-slider [step]="1" min="0" [max]="timeline.length - 1" value="0" (input)="update()"></mat-slider>
<button mat-stroked-button (click)="move(-1)"><</button><button mat-stroked-button (click)="move(1)">></button>
<div class="status">
Timeline:&nbsp;
<mat-slider [disabled]="isPlaying" [step]="1" min="0" [max]="timeline.length - 1" value="0" (input)="update()"></mat-slider>
<h3 class="timestamp">{{ currentFrame.timestamp / 1000 | number }} seconds from the start</h3>
</div>
<div class="control-buttons">
<button [disabled]="isPlaying" mat-stroked-button (click)="moveToBeginningOfTimeline()">
<<
</button>
<button [disabled]="isPlaying" mat-stroked-button (click)="move(-1)">
<
</button>
<button [disabled]="isPlaying" mat-stroked-button (click)="move(1)">
>
</button>
<button [disabled]="isPlaying" mat-stroked-button (click)="moveToEndOfTimeline()">
>>
</button>
<button mat-stroked-button (click)="isPlaying ? pause() : play()">
<ng-container *ngIf="isPlaying; else paused">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" width="1em" height="1em"
preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24">
<path d="M8 7h3v10H8zm5 0h3v10h-3z" fill="#626262"/>
</svg>
</ng-container>
<ng-template #paused>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" width="1em" height="1em"
preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24">
<path d="M7 6v12l10-6z" fill="#626262"/>
</svg>
</ng-template>
</button>
</div>
</div>
<h3 class="timestamp">{{ currentFrame.timestamp / 1000 | number }} seconds from the start</h3>
<div
class="network-canvas"
[visNetwork]="visNetwork"

View file

@ -6,6 +6,7 @@ import { Options, Edge, DataSet, Data, Node, VisNetworkService } from 'ngx-vis';
import { ComponentRecord } from 'protocol';
import { Timeline, TimelineNode, buildTimeline, TimelineNodeState, TimelineFrame } from './time-travel-builder';
import { MatSlider } from '@angular/material/slider';
import { interval, Observable, range, Subscription, zip } from 'rxjs';
@Component({
selector: 'ng-time-travel',
@ -54,6 +55,9 @@ export class TimeTravelComponent implements OnDestroy {
private _nodeIdToNodes: Map<string, TimelineNode>;
isPlaying = false;
playLoopSubscription: Subscription;
constructor(private _visNetworkService: VisNetworkService) {}
@Input() set stream(stream: ComponentRecord[]) {
@ -61,13 +65,13 @@ export class TimeTravelComponent implements OnDestroy {
this._showFrame(this.timeline[0]);
}
showSeparator() {
return Object.keys(this.selectedEntry.instanceState.props).length > 0 && this.selectedEntry.duration;
showSeparator(): boolean {
return Object.keys(this.selectedEntry.instanceState.props).length > 0 && !!this.selectedEntry.duration;
}
move(direction: number) {
move(direction: number): void {
const idx = this.slider.value + direction;
if (idx >= this.timeline.length || idx < 0) {
if (this._invalidSliderIndex(idx)) {
return;
}
this.slider.value = idx;
@ -75,13 +79,44 @@ export class TimeTravelComponent implements OnDestroy {
this._visNetworkService.setData(this.visNetwork, this.visNetworkData);
}
update() {
createPlayLoopObservable(): Observable<[number, number]> {
return zip(
range(0, this.timeline.length - this.slider.value),
interval(300),
);
}
play(): void {
this.isPlaying = true;
this._clearPlayLoopSubscription();
this._subscribeToPlayLoop();
}
pause(): void {
this.isPlaying = false;
this._clearPlayLoopSubscription();
}
moveToBeginningOfTimeline(): void {
this.slider.value = 0;
this._showFrame(this.timeline[0]);
this._visNetworkService.setData(this.visNetwork, this.visNetworkData);
}
moveToEndOfTimeline(): void {
const lastIndex = this.timeline.length - 1;
this.slider.value = lastIndex;
this._showFrame(this.timeline[lastIndex]);
this._visNetworkService.setData(this.visNetwork, this.visNetworkData);
}
update(): void {
const idx = this.slider.value;
this._showFrame(this.timeline[idx]);
this._visNetworkService.setData(this.visNetwork, this.visNetworkData);
}
onInitialize() {
onInitialize(): void {
this._visNetworkService.on(this.visNetwork, 'click');
this._visNetworkService.click.subscribe((eventData: any[]) => {
if (eventData[0] === this.visNetwork) {
@ -90,53 +125,87 @@ export class TimeTravelComponent implements OnDestroy {
});
}
ngOnDestroy() {
ngOnDestroy(): void {
this._clearPlayLoopSubscription();
this._visNetworkService.off(this.visNetwork, 'click');
}
private _showFrame(frame: TimelineFrame | undefined) {
private _showFrame(frame: TimelineFrame | undefined): void {
if (!frame) {
return;
}
this._prepareNodesAndEdgesForNewFrame(frame);
this._initNodesAndEdges(frame.roots);
this._updateVisNetworkDataState();
}
private _prepareNodesAndEdgesForNewFrame(frame: TimelineFrame): void {
this.currentFrame = frame;
this._resetNodesAndEdges();
this.selectedEntry = null;
}
private _resetNodesAndEdges(): void {
this.nodes = new DataSet<Node>([]);
this.edges = new DataSet<Edge>([]);
this._nodeIdToNodes = new Map<string, TimelineNode>();
}
const initNodes = (roots: TimelineNode[], parentId?: string) => {
roots.forEach(node => {
if (!node) {
return;
}
private _initNodesAndEdges(roots: TimelineNode[], parentId?: string): void {
roots.forEach(node => {
if (!node) {
return;
}
const id = uuid();
const id = uuid();
this._nodeIdToNodes.set(id, node);
this._nodeIdToNodes.set(id, node);
this.nodes.add({
id,
color: node.state === TimelineNodeState.Check ? '#62D7C5' : '#5727E5',
label: node.name,
font: {
color: node.state === TimelineNodeState.Check ? '#000000' : '#ffffff',
},
});
if (parentId) {
this.edges.add({
from: parentId,
to: id
});
}
initNodes(node.children, id);
this.nodes.add({
id,
color: node.state === TimelineNodeState.Check ? '#62D7C5' : '#5727E5',
label: node.name,
font: {
color: node.state === TimelineNodeState.Check ? '#000000' : '#ffffff',
},
});
};
initNodes(frame.roots);
this.selectedEntry = null;
if (parentId) {
this.edges.add({
from: parentId,
to: id
});
}
this._initNodesAndEdges(node.children, id);
});
}
private _updateVisNetworkDataState(): void {
this.visNetworkData = { nodes: this.nodes, edges: this.edges };
}
private _invalidSliderIndex(index: number): boolean {
return index >= this.timeline.length || index < 0;
}
private _clearPlayLoopSubscription(): void {
if (this.playLoopSubscription) {
this.playLoopSubscription.unsubscribe();
}
}
private _subscribeToPlayLoop(): void {
this.playLoopSubscription = this.createPlayLoopObservable().subscribe(snapshot => {
if (this._reachedEndOfTimeLine()) {
this.pause();
} else {
this.move(1);
}
});
}
private _reachedEndOfTimeLine(): boolean {
return (this.timeline.length - 1) - this.slider.value === 0;
}
}