From 7f4c6352ba509002a5c62d4d9112a6f93346de08 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 14 Jun 2022 16:39:37 +0000 Subject: [PATCH] Add visualization to mined blocks --- .../block-overview-graph.component.ts | 11 +- .../block-overview-graph/block-scene.ts | 109 +++++-- .../app/components/block/block.component.html | 275 +++++++++++------- .../app/components/block/block.component.scss | 7 + .../app/components/block/block.component.ts | 84 +++++- .../mempool-block-overview.component.html | 6 +- .../src/app/interfaces/node-api.interface.ts | 7 + frontend/src/app/services/api.service.ts | 7 +- 8 files changed, 366 insertions(+), 140 deletions(-) diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index cbb0225ff..c596691ad 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -15,6 +15,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit { @Input() isLoading: boolean; @Input() resolution: number; @Input() blockLimit: number; + @Input() orientation = 'left'; + @Input() flip = true; @Output() txPreviewEvent = new EventEmitter(); @ViewChild('blockCanvas') @@ -67,9 +69,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit { } } - replace(transactions: TransactionStripped[], direction: string): void { + replace(transactions: TransactionStripped[], direction: string, sort: boolean = true): void { if (this.scene) { - this.scene.replace(transactions, direction); + this.scene.replace(transactions || [], direction, sort); } } @@ -139,8 +141,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit { if (this.scene) { this.scene.resize({ width: this.displayWidth, height: this.displayHeight }); } else { - this.scene = this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, - blockLimit: this.blockLimit, vertexArray: this.vertexArray }); + this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, + blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray }); + this.start(); } } diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index b71c1ab6c..ffae2ed6a 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -7,6 +7,8 @@ export default class BlockScene { scene: { count: number, offset: { x: number, y: number}}; vertexArray: FastVertexArray; txs: { [key: string]: TxView }; + orientation: string; + flip: boolean; width: number; height: number; gridWidth: number; @@ -19,10 +21,11 @@ export default class BlockScene { layout: BlockLayout; dirty: boolean; - constructor({ width, height, resolution, blockLimit, vertexArray }: - { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray } + constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: + { width: number, height: number, resolution: number, blockLimit: number, + orientation: string, flip: boolean, vertexArray: FastVertexArray } ) { - this.init({ width, height, resolution, blockLimit, vertexArray }); + this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }); } destroy(): void { @@ -61,7 +64,7 @@ export default class BlockScene { } // Reset layout and replace with new set of transactions - replace(txs: TransactionStripped[], direction: string = 'left'): void { + replace(txs: TransactionStripped[], direction: string = 'left', sort: boolean = true): void { const startTime = performance.now(); const nextIds = {}; const remove = []; @@ -90,9 +93,15 @@ export default class BlockScene { this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); - Object.values(this.txs).sort(feeRateDescending).forEach(tx => { - this.place(tx); - }); + if (sort) { + Object.values(this.txs).sort(feeRateDescending).forEach(tx => { + this.place(tx); + }); + } else { + txs.forEach(tx => { + this.place(this.txs[tx.txid]); + }); + } this.updateAll(startTime, direction); } @@ -143,9 +152,12 @@ export default class BlockScene { } } - private init({ width, height, resolution, blockLimit, vertexArray }: - { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray } + private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: + { width: number, height: number, resolution: number, blockLimit: number, + orientation: string, flip: boolean, vertexArray: FastVertexArray } ): void { + this.orientation = orientation; + this.flip = flip; this.vertexArray = vertexArray; this.scene = { @@ -188,8 +200,8 @@ export default class BlockScene { tx.update({ display: { position: { - x: tx.screenPosition.x + (direction === 'right' ? -this.width : this.width) * 1.4, - y: tx.screenPosition.y, + x: tx.screenPosition.x + (direction === 'right' ? -this.width : (direction === 'left' ? this.width : 0)) * 1.4, + y: tx.screenPosition.y + (direction === 'up' ? -this.height : (direction === 'down' ? this.height : 0)) * 1.4, s: tx.screenPosition.s }, color: txColor, @@ -237,8 +249,8 @@ export default class BlockScene { tx.update({ display: { position: { - x: tx.screenPosition.x + (direction === 'right' ? this.width : -this.width) * 1.4, - y: this.txs[id].screenPosition.y, + x: tx.screenPosition.x + (direction === 'right' ? this.width : (direction === 'left' ? -this.width : 0)) * 1.4, + y: tx.screenPosition.y + (direction === 'up' ? this.height : (direction === 'down' ? -this.height : 0)) * 1.4, } }, duration: 1000, @@ -264,18 +276,42 @@ export default class BlockScene { const slotSize = (position.s * this.gridSize); const squareSize = slotSize - (this.unitPadding * 2); - // The grid is laid out notionally left-to-right, bottom-to-top - // So we rotate 90deg counterclockwise then flip the y axis + // The grid is laid out notionally left-to-right, bottom-to-top, + // so we rotate and/or flip the y axis to match the target configuration. + // + // e.g. for flip = true, orientation = 'left': // // grid screen - // ________ ________ ________ - // | | | b| | a| - // | | rotate | | flip | c | - // | c | --> | c | --> | | - // |a______b| |_______a| |_______b| + // ________ ________ ________ + // | | | | | a| + // | | flip | | rotate | c | + // | c | --> | c | --> | | + // |a______b| |b______a| |_______b| + + let x = (this.gridSize * position.x) + (slotSize / 2); + let y = (this.gridSize * position.y) + (slotSize / 2); + let t; + if (this.flip) { + x = this.width - x; + } + switch (this.orientation) { + case 'left': + t = x; + x = this.width - y; + y = t; + break; + case 'right': + t = x; + x = y; + y = t; + break; + case 'bottom': + y = this.height - y; + break; + } return { - x: this.width + (this.unitPadding * 2) - (this.gridSize * position.y) - slotSize, - y: this.height - ((this.gridSize * position.x) + (slotSize - this.unitPadding)), + x: x + this.unitPadding - (slotSize / 2), + y: y + this.unitPadding - (slotSize / 2), s: squareSize }; } else { @@ -284,11 +320,32 @@ export default class BlockScene { } screenToGrid(position: Position): Position { - const grid = { - x: Math.floor((position.y - this.unitPadding) / this.gridSize), - y: Math.floor((this.width + (this.unitPadding * 2) - position.x) / this.gridSize) + let x = position.x; + let y = this.height - position.y; + let t; + + switch (this.orientation) { + case 'left': + t = x; + x = y; + y = this.width - t; + break; + case 'right': + t = x; + x = y; + y = t; + break; + case 'bottom': + y = this.height - y; + break; + } + if (this.flip) { + x = this.width - x; + } + return { + x: Math.floor(x / this.gridSize), + y: Math.floor(y / this.gridSize) }; - return grid; } // calculates and returns the size of the tx in multiples of the grid size diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index 00fc18f2a..fdf5caf4e 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -40,10 +40,11 @@
- -
-
+ +
+
+
@@ -68,73 +69,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Weight
Median fee~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB
Total fees + + + + + +   +
Subsidy + fees: + + + + +
Total fees
Subsidy + fees:
Miner + + {{ block.extras.pool.name }} + + + + {{ block.extras.pool.name }} + +
+
+
- - - + + - + + + + + + + + + + - - - - - + - - - + + + + + + + + - - - - - - - - - - - - - - -
Median fee~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB
Total fees - - - - - -   -
Subsidy + fees: - - - - -
Total fees
Subsidy + fees:
Miner - - {{ block.extras.pool.name }} - - - - {{ block.extras.pool.name }} - -
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Median fee~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB
Total fees + + + + + +   +
Subsidy + fees: + + + + +
Total fees
Subsidy + fees:
Miner + + {{ block.extras.pool.name }} + + + + {{ block.extras.pool.name }} + +
+ + + + + + + + + + + + + + + +
+
+
+
- +
+

@@ -223,63 +342,17 @@
+
-
- - - - -
-
-
- - - - - - - - - - - - - - - -
-
-
- - - - - - - - - - - - - - - -
-
-
-
-
-
Error loading data. diff --git a/frontend/src/app/components/block/block.component.scss b/frontend/src/app/components/block/block.component.scss index 067d250e2..f047cbcfa 100644 --- a/frontend/src/app/components/block/block.component.scss +++ b/frontend/src/app/components/block/block.component.scss @@ -148,3 +148,10 @@ h1 { } } } + +.chart-container{ + margin: 20px auto; + @media (min-width: 768px) { + margin: auto; + } +} diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index bd70e8628..4ffacabaa 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -2,15 +2,16 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/co import { Location } from '@angular/common'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; -import { switchMap, tap, debounceTime, catchError, map } from 'rxjs/operators'; +import { switchMap, tap, debounceTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators'; import { Transaction, Vout } from '../../interfaces/electrs.interface'; import { Observable, of, Subscription } from 'rxjs'; import { StateService } from '../../services/state.service'; import { SeoService } from 'src/app/services/seo.service'; import { WebsocketService } from 'src/app/services/websocket.service'; import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; -import { BlockExtended } from 'src/app/interfaces/node-api.interface'; +import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface'; import { ApiService } from 'src/app/services/api.service'; +import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component'; @Component({ selector: 'app-block', @@ -21,6 +22,7 @@ export class BlockComponent implements OnInit, OnDestroy { network = ''; block: BlockExtended; blockHeight: number; + lastBlockHeight: number; nextBlockHeight: number; blockHash: string; isLoadingBlock = true; @@ -28,6 +30,10 @@ export class BlockComponent implements OnInit, OnDestroy { latestBlocks: BlockExtended[] = []; transactions: Transaction[]; isLoadingTransactions = true; + strippedTransactions: TransactionStripped[]; + overviewTransitionDirection: string; + isLoadingOverview = true; + isAwaitingOverview = true; error: any; blockSubsidy: number; fees: number; @@ -39,13 +45,18 @@ export class BlockComponent implements OnInit, OnDestroy { showPreviousBlocklink = true; showNextBlocklink = true; transactionsError: any = null; + overviewError: any = null; + webGlEnabled = true; - subscription: Subscription; + transactionSubscription: Subscription; + overviewSubscription: Subscription; keyNavigationSubscription: Subscription; blocksSubscription: Subscription; networkChangedSubscription: Subscription; queryParamsSubscription: Subscription; + @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; + constructor( private route: ActivatedRoute, private location: Location, @@ -56,7 +67,9 @@ export class BlockComponent implements OnInit, OnDestroy { private websocketService: WebsocketService, private relativeUrlPipe: RelativeUrlPipe, private apiService: ApiService - ) { } + ) { + this.webGlEnabled = detectWebGL(); + } ngOnInit() { this.websocketService.want(['blocks', 'mempool-blocks']); @@ -85,7 +98,7 @@ export class BlockComponent implements OnInit, OnDestroy { } }); - this.subscription = this.route.paramMap.pipe( + const block$ = this.route.paramMap.pipe( switchMap((params: ParamMap) => { const blockHash: string = params.get('id') || ''; this.block = undefined; @@ -141,6 +154,8 @@ export class BlockComponent implements OnInit, OnDestroy { tap((block: BlockExtended) => { this.block = block; this.blockHeight = block.height; + const direction = (this.lastBlockHeight < this.blockHeight) ? 'right' : 'left'; + this.lastBlockHeight = this.blockHeight; this.nextBlockHeight = block.height + 1; this.setNextAndPreviousBlockLink(); @@ -154,8 +169,17 @@ export class BlockComponent implements OnInit, OnDestroy { this.isLoadingTransactions = true; this.transactions = null; this.transactionsError = null; + this.isLoadingOverview = true; + this.isAwaitingOverview = true; + this.overviewError = true; + if (this.blockGraph) { + this.blockGraph.exit(direction); + } }), debounceTime(300), + shareReplay(1) + ); + this.transactionSubscription = block$.pipe( switchMap((block) => this.electrsApiService.getBlockTransactions$(block.id) .pipe( catchError((err) => { @@ -170,10 +194,51 @@ export class BlockComponent implements OnInit, OnDestroy { } this.transactions = transactions; this.isLoadingTransactions = false; + + if (!this.isAwaitingOverview && this.blockGraph && this.strippedTransactions && this.overviewTransitionDirection) { + this.isLoadingOverview = false; + this.blockGraph.replace(this.strippedTransactions, this.overviewTransitionDirection, false); + } }, (error) => { this.error = error; this.isLoadingBlock = false; + this.isLoadingOverview = false; + }); + + this.overviewSubscription = block$.pipe( + startWith(null), + pairwise(), + switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id) + .pipe( + catchError((err) => { + this.overviewError = err; + return of([]); + }), + switchMap((transactions) => { + console.log('overview loaded: ', prevBlock && prevBlock.height, block.height); + if (prevBlock) { + return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' }); + } else { + return of({ transactions, direction: 'down' }); + } + }) + ) + ), + ) + .subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { + this.isAwaitingOverview = false; + this.strippedTransactions = transactions; + this.overviewTransitionDirection = direction; + if (!this.isLoadingTransactions && this.blockGraph) { + this.isLoadingOverview = false; + this.blockGraph.replace(this.strippedTransactions, this.overviewTransitionDirection, false); + } + }, + (error) => { + this.error = error; + this.isLoadingOverview = false; + this.isAwaitingOverview = false; }); this.networkChangedSubscription = this.stateService.networkChanged$ @@ -203,7 +268,8 @@ export class BlockComponent implements OnInit, OnDestroy { ngOnDestroy() { this.stateService.markBlock$.next({}); - this.subscription.unsubscribe(); + this.transactionSubscription.unsubscribe(); + this.overviewSubscription.unsubscribe(); this.keyNavigationSubscription.unsubscribe(); this.blocksSubscription.unsubscribe(); this.networkChangedSubscription.unsubscribe(); @@ -303,3 +369,9 @@ export class BlockComponent implements OnInit, OnDestroy { } } } + +function detectWebGL() { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + return (gl && gl instanceof WebGLRenderingContext); +} diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html index c9696f222..2b6ff37a5 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html @@ -3,5 +3,7 @@ [isLoading]="isLoading$ | async" [resolution]="75" [blockLimit]="stateService.blockVSize" - (txPreviewEvent)="onTxPreview($event)"> - + [orientation]="'left'" + [flip]="true" + (txPreviewEvent)="onTxPreview($event)" +> diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index d1f9932d7..6d3e7c0d8 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -128,6 +128,13 @@ export interface BlockExtended extends Block { extras?: BlockExtension; } +export interface TransactionStripped { + txid: string; + fee: number; + vsize: number; + value: number; +} + export interface RewardStats { startBlock: number; endBlock: number; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 134877f72..8202fbb49 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolsStats, PoolStat, BlockExtended, RewardStats } from '../interfaces/node-api.interface'; +import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, + PoolsStats, PoolStat, BlockExtended, TransactionStripped, RewardStats } from '../interfaces/node-api.interface'; import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; @@ -158,6 +159,10 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash); } + getStrippedBlockTransactions$(hash: string): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash + '/summary'); + } + getHistoricalHashrate$(interval: string | undefined): Observable { return this.httpClient.get( this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/hashrate` +