import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, OnInit } from '@angular/core'; import { combineLatest, merge, Observable, of, timer } from 'rxjs'; import { filter, map, scan, share, switchMap, tap } from 'rxjs/operators'; import { Block } from '../interfaces/electrs.interface'; import { OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { MempoolInfo, TransactionStripped } from '../interfaces/websocket.interface'; import { ApiService } from '../services/api.service'; import { StateService } from '../services/state.service'; import { WebsocketService } from '../services/websocket.service'; import { SeoService } from '../services/seo.service'; import { StorageService } from '../services/storage.service'; interface MempoolBlocksData { blocks: number; size: number; } interface EpochProgress { base: string; change: number; progress: string; remainingBlocks: number; newDifficultyHeight: number; colorAdjustments: string; colorPreviousAdjustments: string; timeAvg: string; remainingTime: number; previousRetarget: number; } interface MempoolInfoData { memPoolInfo: MempoolInfo; vBytesPerSecond: number; progressWidth: string; progressColor: string; } interface MempoolStatsData { mempool: OptimizedMempoolStats[]; weightPerSecond: any; } @Component({ selector: 'app-dashboard', templateUrl: './dashboard.component.html', styleUrls: ['./dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class DashboardComponent implements OnInit { collapseLevel: string; network$: Observable; mempoolBlocksData$: Observable; mempoolInfoData$: Observable; difficultyEpoch$: Observable; mempoolLoadingStatus$: Observable; vBytesPerSecondLimit = 1667; blocks$: Observable; transactions$: Observable; latestBlockHeight: number; mempoolTransactionsWeightPerSecondData: any; mempoolStats$: Observable; transactionsWeightPerSecondOptions: any; isLoadingWebSocket$: Observable; liquidPegsMonth$: Observable; constructor( @Inject(LOCALE_ID) private locale: string, public stateService: StateService, private apiService: ApiService, private websocketService: WebsocketService, private seoService: SeoService, private storageService: StorageService, ) { } ngOnInit(): void { this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; this.seoService.resetTitle(); this.websocketService.want(['blocks', 'stats', 'mempool-blocks', 'live-2h-chart']); this.network$ = merge(of(''), this.stateService.networkChanged$); this.collapseLevel = this.storageService.getValue('dashboard-collapsed') || 'one'; this.mempoolLoadingStatus$ = this.stateService.loadingIndicators$ .pipe( map((indicators) => indicators.mempool !== undefined ? indicators.mempool : 100) ); this.mempoolInfoData$ = combineLatest([ this.stateService.mempoolInfo$, this.stateService.vbytesPerSecond$ ]) .pipe( map(([mempoolInfo, vbytesPerSecond]) => { const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100); let progressColor = '#7CB342'; if (vbytesPerSecond > 1667) { progressColor = '#FDD835'; } if (vbytesPerSecond > 2000) { progressColor = '#FFB300'; } if (vbytesPerSecond > 2500) { progressColor = '#FB8C00'; } if (vbytesPerSecond > 3000) { progressColor = '#F4511E'; } if (vbytesPerSecond > 3500) { progressColor = '#D81B60'; } const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100); let mempoolSizeProgress = 'bg-danger'; if (mempoolSizePercentage <= 50) { mempoolSizeProgress = 'bg-success'; } else if (mempoolSizePercentage <= 75) { mempoolSizeProgress = 'bg-warning'; } return { memPoolInfo: mempoolInfo, vBytesPerSecond: vbytesPerSecond, progressWidth: percent + '%', progressColor: progressColor, mempoolSizeProgress: mempoolSizeProgress, }; }) ); this.difficultyEpoch$ = timer(0, 1000) .pipe( switchMap(() => combineLatest([ this.stateService.blocks$.pipe(map(([block]) => block)), this.stateService.lastDifficultyAdjustment$, this.stateService.previousRetarget$ ])), map(([block, DATime, previousRetarget]) => { const now = new Date().getTime() / 1000; const diff = now - DATime; const blocksInEpoch = block.height % 2016; const progress = (blocksInEpoch >= 0) ? (blocksInEpoch / 2016 * 100).toFixed(2) : `100`; const remainingBlocks = 2016 - blocksInEpoch; const newDifficultyHeight = block.height + remainingBlocks; let change = 0; if (remainingBlocks < 1870) { if (blocksInEpoch > 0) { change = (600 / (diff / blocksInEpoch ) - 1) * 100; } if (change > 300) { change = 300; } if (change < -75) { change = -75; } } const timeAvgDiff = change * 0.1; let timeAvgMins = 10; if (timeAvgDiff > 0) { timeAvgMins -= Math.abs(timeAvgDiff); } else { timeAvgMins += Math.abs(timeAvgDiff); } const timeAvg = timeAvgMins.toFixed(0); const remainingTime = (remainingBlocks * timeAvgMins * 60 * 1000) + (now * 1000); let colorAdjustments = '#ffffff66'; if (change > 0) { colorAdjustments = '#3bcc49'; } if (change < 0) { colorAdjustments = '#dc3545'; } let colorPreviousAdjustments = '#dc3545'; if (previousRetarget) { if (previousRetarget >= 0) { colorPreviousAdjustments = '#3bcc49'; } if (previousRetarget === 0) { colorPreviousAdjustments = '#ffffff66'; } } else { colorPreviousAdjustments = '#ffffff66'; } return { base: `${progress}%`, change, progress, remainingBlocks, timeAvg, colorAdjustments, colorPreviousAdjustments, blocksInEpoch, newDifficultyHeight, remainingTime, previousRetarget, }; }) ); this.mempoolBlocksData$ = this.stateService.mempoolBlocks$ .pipe( map((mempoolBlocks) => { const size = mempoolBlocks.map((m) => m.blockSize).reduce((a, b) => a + b, 0); const vsize = mempoolBlocks.map((m) => m.blockVSize).reduce((a, b) => a + b, 0); return { size: size, blocks: Math.ceil(vsize / this.stateService.blockVSize) }; }) ); this.blocks$ = this.stateService.blocks$ .pipe( tap(([block]) => { this.latestBlockHeight = block.height; }), scan((acc, [block]) => { acc.unshift(block); acc = acc.slice(0, 6); return acc; }, []), ); this.transactions$ = this.stateService.transactions$ .pipe( scan((acc, tx) => { acc.unshift(tx); acc = acc.slice(0, 6); return acc; }, []), ); this.mempoolStats$ = this.stateService.connectionState$ .pipe( filter((state) => state === 2), switchMap(() => this.apiService.list2HStatistics$()), switchMap((mempoolStats) => { return merge( this.stateService.live2Chart$ .pipe( scan((acc, stats) => { acc.unshift(stats); acc = acc.slice(0, 120); return acc; }, mempoolStats) ), of(mempoolStats) ); }), map((mempoolStats) => { return { mempool: mempoolStats, weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])), }; }), share(), ); if (this.stateService.network === 'liquid') { this.liquidPegsMonth$ = this.apiService.listLiquidPegsMonth$() .pipe( map((pegs) => { const labels = pegs.map(stats => stats.date); const series = pegs.map(stats => parseFloat(stats.amount) / 100000000); series.reduce((prev, curr, i) => series[i] = prev + curr, 0); return { series, labels }; }), share(), ); } } handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) { mempoolStats.reverse(); const labels = mempoolStats.map(stats => stats.added); return { labels: labels, series: [mempoolStats.map((stats) => [stats.added * 1000, stats.vbytes_per_second])], }; } trackByBlock(index: number, block: Block) { return block.height; } toggleCollapsed() { if (this.collapseLevel === 'one') { this.collapseLevel = 'two'; } else if (this.collapseLevel === 'two') { this.collapseLevel = 'three'; } else { this.collapseLevel = 'one'; } this.storageService.setValue('dashboard-collapsed', this.collapseLevel); } }