From befafaa60c1b6e8362cc0e80bcc465e2eb8a7efe Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 27 Dec 2022 05:28:57 -0600 Subject: [PATCH] add paginated virtual scrolling to blockchain blocks bar --- backend/src/api/bitcoin/bitcoin.routes.ts | 3 +- backend/src/api/blocks.ts | 5 + .../src/app/components/app/app.component.ts | 4 + .../blockchain-blocks.component.html | 65 ++++--- .../blockchain-blocks.component.ts | 162 +++++++++++++----- .../blockchain/blockchain.component.html | 9 +- .../blockchain/blockchain.component.ts | 10 +- .../app/components/start/start.component.html | 3 +- .../app/components/start/start.component.ts | 146 +++++++++++++++- frontend/src/app/services/state.service.ts | 22 ++- .../src/app/services/websocket.service.ts | 6 +- 11 files changed, 355 insertions(+), 80 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 55500d0c9..2d77969a1 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -402,7 +402,8 @@ class BitcoinRoutes { private async getLegacyBlocks(req: Request, res: Response) { try { const returnBlocks: IEsploraApi.Block[] = []; - const fromHeight = parseInt(req.params.height, 10) || blocks.getCurrentBlockHeight(); + const tip = blocks.getCurrentBlockHeight(); + const fromHeight = Math.min(parseInt(req.params.height, 10) || tip, tip); // Check if block height exist in local cache to skip the hash lookup const blockByHeight = blocks.getBlocks().find((b) => b.height === fromHeight); diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 472ef48ef..b505c01e0 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -677,7 +677,12 @@ class Blocks { } public async $getBlocks(fromHeight?: number, limit: number = 15): Promise { + let currentHeight = fromHeight !== undefined ? fromHeight : this.currentBlockHeight; + if (currentHeight > this.currentBlockHeight) { + limit -= currentHeight - this.currentBlockHeight; + currentHeight = this.currentBlockHeight; + } const returnBlocks: BlockExtended[] = []; if (currentHeight < 0) { diff --git a/frontend/src/app/components/app/app.component.ts b/frontend/src/app/components/app/app.component.ts index d9d6f77d6..c7ca798ae 100644 --- a/frontend/src/app/components/app/app.component.ts +++ b/frontend/src/app/components/app/app.component.ts @@ -42,6 +42,10 @@ export class AppComponent implements OnInit { if (event.target instanceof HTMLInputElement) { return; } + // prevent arrow key horizontal scrolling + if(["ArrowLeft","ArrowRight"].indexOf(event.code) > -1) { + event.preventDefault(); + } this.stateService.keyNavigation$.next(event); } diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html index 6bd617435..9a305833f 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html @@ -1,34 +1,47 @@ -
+
-
-   -
- {{ block.height }} + +
+   + +
+
+ ~{{ block?.extras?.medianFee | number:feeRounding }} sat/vB +
+
+ {{ block?.extras?.feeRange[1] | number:feeRounding }} - {{ block?.extras?.feeRange[block?.extras?.feeRange.length - 1] | number:feeRounding }} sat/vB +
+
+ +
+
+
+ + {{ i }} transaction + {{ i }} transactions +
+
+
+
-
-
- ~{{ block?.extras?.medianFee | number:feeRounding }} sat/vB + + +
+   +
+ {{ block.height }}
-
- {{ block?.extras?.feeRange[1] | number:feeRounding }} - {{ block?.extras?.feeRange[block?.extras?.feeRange.length - 1] | number:feeRounding }} sat/vB +
+ loading
-
- -
-
-
- - {{ i }} transaction - {{ i }} transactions -
-
- -
+
diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts index 8ac925eaf..930fa2ea8 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, Input, OnChanges, SimpleChanges } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; import { StateService } from '../../services/state.service'; import { specialBlocks } from '../../app.constants'; @@ -6,16 +6,25 @@ import { BlockExtended } from '../../interfaces/node-api.interface'; import { Location } from '@angular/common'; import { config } from 'process'; +interface BlockchainBlock extends BlockExtended { + loading?: boolean; +} + @Component({ selector: 'app-blockchain-blocks', templateUrl: './blockchain-blocks.component.html', styleUrls: ['./blockchain-blocks.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BlockchainBlocksComponent implements OnInit, OnDestroy { +export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { + @Input() static: boolean = false; + @Input() offset: number = 0; + @Input() height: number = 0; + @Input() count: number = 8; + specialBlocks = specialBlocks; network = ''; - blocks: BlockExtended[] = []; + blocks: BlockchainBlock[] = []; emptyBlocks: BlockExtended[] = this.mountEmptyBlocks(); markHeight: number; blocksSubscription: Subscription; @@ -75,44 +84,46 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { this.loadingBlocks$ = this.stateService.isLoadingWebSocket$; this.networkSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network); this.tabHiddenSubscription = this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); - this.blocksSubscription = this.stateService.blocks$ - .subscribe(([block, txConfirmed]) => { - if (this.blocks.some((b) => b.height === block.height)) { - return; - } + if (!this.static) { + this.blocksSubscription = this.stateService.blocks$ + .subscribe(([block, txConfirmed]) => { + if (this.blocks.some((b) => b.height === block.height)) { + return; + } - if (this.blocks.length && block.height !== this.blocks[0].height + 1) { - this.blocks = []; - this.blocksFilled = false; - } + if (this.blocks.length && block.height !== this.blocks[0].height + 1) { + this.blocks = []; + this.blocksFilled = false; + } - this.blocks.unshift(block); - this.blocks = this.blocks.slice(0, this.stateService.env.KEEP_BLOCKS_AMOUNT); + this.blocks.unshift(block); + this.blocks = this.blocks.slice(0, this.stateService.env.KEEP_BLOCKS_AMOUNT); - if (this.blocksFilled && !this.tabHidden && block.extras) { - block.extras.stage = block.extras.matchRate >= 66 ? 1 : 2; - } + if (this.blocksFilled && !this.tabHidden && block.extras) { + block.extras.stage = block.extras.matchRate >= 66 ? 1 : 2; + } - if (txConfirmed) { - this.markHeight = block.height; - this.moveArrowToPosition(true, true); - } else { - this.moveArrowToPosition(true, false); - } + if (txConfirmed) { + this.markHeight = block.height; + this.moveArrowToPosition(true, true); + } else { + this.moveArrowToPosition(true, false); + } - this.blockStyles = []; - this.blocks.forEach((b) => this.blockStyles.push(this.getStyleForBlock(b))); - setTimeout(() => { this.blockStyles = []; - this.blocks.forEach((b) => this.blockStyles.push(this.getStyleForBlock(b))); - this.cd.markForCheck(); - }, 50); + this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i))); + setTimeout(() => { + this.blockStyles = []; + this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i))); + this.cd.markForCheck(); + }, 50); - if (this.blocks.length === this.stateService.env.KEEP_BLOCKS_AMOUNT) { - this.blocksFilled = true; - } - this.cd.markForCheck(); - }); + if (this.blocks.length === this.stateService.env.KEEP_BLOCKS_AMOUNT) { + this.blocksFilled = true; + } + this.cd.markForCheck(); + }); + } this.markBlockSubscription = this.stateService.markBlock$ .subscribe((state) => { @@ -123,10 +134,23 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { this.moveArrowToPosition(false); this.cd.markForCheck(); }); + + if (this.static) { + this.updateStaticBlocks(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (this.static) { + const animateSlide = changes.height && (changes.height.currentValue === changes.height.previousValue + 1); + this.updateStaticBlocks(animateSlide); + } } ngOnDestroy() { - this.blocksSubscription.unsubscribe(); + if (this.blocksSubscription) { + this.blocksSubscription.unsubscribe(); + } this.networkSubscription.unsubscribe(); this.tabHiddenSubscription.unsubscribe(); this.markBlockSubscription.unsubscribe(); @@ -161,24 +185,73 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { }); } } + } else { + this.arrowVisible = false; } } - trackByBlocksFn(index: number, item: BlockExtended) { + trackByBlocksFn(index: number, item: BlockchainBlock) { return item.height; } - getStyleForBlock(block: BlockExtended) { + updateStaticBlocks(animateSlide: boolean = false) { + // reset blocks + this.blocks = []; + this.blockStyles = []; + while (this.blocks.length < Math.min(this.height + 1, this.count)) { + const height = this.height - this.blocks.length; + if (height >= 0) { + // const block = this.cacheService.getCachedBlock(height) || null; + // if (!block) { + // this.cacheService.loadBlock(height); + // } + // this.blocks.push(block || { + this.blocks.push({ + loading: true, + id: '', + height, + version: 0, + timestamp: 0, + bits: 0, + nonce: 0, + difficulty: 0, + merkle_root: '', + tx_count: 0, + size: 0, + weight: 0, + previousblockhash: '', + }); + } + } + this.blocks = this.blocks.slice(0, this.count); + this.blockStyles = []; + this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, animateSlide))); + if (animateSlide) { + // animate blocks slide right + setTimeout(() => { + this.blockStyles = []; + this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i))); + this.cd.markForCheck(); + }, 50); + } + } + + getStyleForBlock(block: BlockchainBlock, index: number, animateSlideStart: boolean = false) { + if (!block || block.loading) { + return this.getStyleForLoadingBlock(index, animateSlideStart); + } const greenBackgroundHeight = 100 - (block.weight / this.stateService.env.BLOCK_WEIGHT_UNITS) * 100; let addLeft = 0; - if (block?.extras?.stage === 1) { - block.extras.stage = 2; + if (animateSlideStart) { + if (block?.extras) { + block.extras.stage = 2; + } addLeft = -205; } return { - left: addLeft + 155 * this.blocks.indexOf(block) + 'px', + left: addLeft + 155 * index + 'px', background: `repeating-linear-gradient( #2d3348, #2d3348 ${greenBackgroundHeight}%, @@ -188,6 +261,15 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { }; } + getStyleForLoadingBlock(index: number, animateSlideStart: boolean = false) { + const addLeft = animateSlideStart ? -205 : 0; + + return { + left: addLeft + (155 * index) + 'px', + background: "#2d3348", + }; + } + getStyleForEmptyBlock(block: BlockExtended) { let addLeft = 0; diff --git a/frontend/src/app/components/blockchain/blockchain.component.html b/frontend/src/app/components/blockchain/blockchain.component.html index 66ae8dd43..542a837ea 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.html +++ b/frontend/src/app/components/blockchain/blockchain.component.html @@ -2,10 +2,13 @@
- - + + + + +
-
+
diff --git a/frontend/src/app/components/blockchain/blockchain.component.ts b/frontend/src/app/components/blockchain/blockchain.component.ts index e99b3532d..c25ebdafd 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.ts +++ b/frontend/src/app/components/blockchain/blockchain.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; +import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, OnChanges, SimpleChanges } from '@angular/core'; import { Subscription } from 'rxjs'; import { StateService } from '../../services/state.service'; @@ -9,6 +9,10 @@ import { StateService } from '../../services/state.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class BlockchainComponent implements OnInit, OnDestroy { + @Input() pages: any[] = []; + @Input() pageIndex: number; + @Input() blocksPerPage: number = 8; + network: string; timeLtrSubscription: Subscription; timeLtr: boolean = this.stateService.timeLtr.value; @@ -29,6 +33,10 @@ export class BlockchainComponent implements OnInit, OnDestroy { this.timeLtrSubscription.unsubscribe(); } + trackByPageFn(index: number, item: { height: number }) { + return item.height; + } + toggleTimeDirection() { this.ltrTransitionEnabled = true; this.stateService.timeLtr.next(!this.timeLtr); diff --git a/frontend/src/app/components/start/start.component.html b/frontend/src/app/components/start/start.component.html index 89b6efdc3..ae4c213cd 100644 --- a/frontend/src/app/components/start/start.component.html +++ b/frontend/src/app/components/start/start.component.html @@ -11,8 +11,9 @@
- +
diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index 37c94baa3..5a70bf15c 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -19,16 +19,33 @@ export class StartComponent implements OnInit, OnDestroy { blockchainScrollLeftInit: number; timeLtrSubscription: Subscription; timeLtr: boolean = this.stateService.timeLtr.value; + chainTipSubscription: Subscription; + chainTip: number = -1; + markBlockSubscription: Subscription; @ViewChild('blockchainContainer') blockchainContainer: ElementRef; + isMobile: boolean = false; + blockWidth = 155; + blocksPerPage: number = 1; + pageWidth: number; + firstPageWidth: number; + pageIndex: number = 0; + pages: any[] = []; + constructor( private stateService: StateService, ) { } ngOnInit() { + this.firstPageWidth = 40 + (this.blockWidth * this.stateService.env.KEEP_BLOCKS_AMOUNT); + this.onResize(); this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { this.timeLtr = !!ltr; }); + this.chainTipSubscription = this.stateService.chainTip$.subscribe((height) => { + this.chainTip = height; + this.updatePages(); + }); this.stateService.blocks$ .subscribe((blocks: any) => { if (this.stateService.network !== '') { @@ -55,6 +72,31 @@ export class StartComponent implements OnInit, OnDestroy { }); } + @HostListener('window:resize', ['$event']) + onResize(): void { + this.isMobile = window.innerWidth <= 767.98; + let firstVisibleBlock; + let offset; + this.pages.forEach(page => { + const left = page.offset - (this.blockchainContainer?.nativeElement?.scrollLeft || 0); + const right = left + this.pageWidth; + if (left <= 0 && right > 0) { + const blockIndex = Math.max(0, Math.floor(left / -this.blockWidth)); + firstVisibleBlock = page.height - blockIndex; + offset = left + (blockIndex * this.blockWidth); + } + }); + + this.blocksPerPage = Math.ceil(window.innerWidth / this.blockWidth); + this.pageWidth = this.blocksPerPage * this.blockWidth; + + if (firstVisibleBlock != null) { + this.scrollToBlock(firstVisibleBlock, offset); + } else { + this.updatePages(); + } + } + onMouseDown(event: MouseEvent) { this.mouseDragStartX = event.clientX; this.blockchainScrollLeftInit = this.blockchainContainer.nativeElement.scrollLeft; @@ -70,7 +112,7 @@ export class StartComponent implements OnInit, OnDestroy { if (this.mouseDragStartX != null) { this.stateService.setBlockScrollingInProgress(true); this.blockchainContainer.nativeElement.scrollLeft = - this.blockchainScrollLeftInit + this.mouseDragStartX - event.clientX + this.blockchainScrollLeftInit + this.mouseDragStartX - event.clientX; } } @HostListener('document:mouseup', []) @@ -79,6 +121,108 @@ export class StartComponent implements OnInit, OnDestroy { this.stateService.setBlockScrollingInProgress(false); } + onScroll(e) { + const middlePage = this.pageIndex === 0 ? this.pages[0] : this.pages[1]; + // compensate for css transform + const translation = (this.isMobile ? window.innerWidth * 0.95 : window.innerWidth * 0.5); + const backThreshold = middlePage.offset + (this.pageWidth * 0.5) + translation; + const forwardThreshold = middlePage.offset - (this.pageWidth * 0.5) + translation; + if (this.timeLtr) { + if (e.target.scrollLeft < -backThreshold) { + if (this.shiftPagesBack()) { + e.target.scrollLeft += this.pageWidth; + } + } else if (e.target.scrollLeft > -forwardThreshold) { + if (this.shiftPagesForward()) { + e.target.scrollLeft -= this.pageWidth; + } + } + } else { + if (e.target.scrollLeft > backThreshold) { + if (this.shiftPagesBack()) { + e.target.scrollLeft -= this.pageWidth; + } + } else if (e.target.scrollLeft < forwardThreshold) { + if (this.shiftPagesForward()) { + e.target.scrollLeft += this.pageWidth; + } + } + } + } + + scrollToBlock(height, blockOffset = 0) { + if (!this.blockchainContainer?.nativeElement) { + setTimeout(() => { this.scrollToBlock(height, blockOffset); }, 50); + return; + } + let targetHeight = this.isMobile ? height - 1 : height; + const middlePageIndex = this.getPageIndexOf(targetHeight); + const pages = []; + if (middlePageIndex > 0) { + this.pageIndex = middlePageIndex - 1; + const middlePage = this.getPageAt(middlePageIndex); + const left = middlePage.offset - this.blockchainContainer.nativeElement.scrollLeft; + const blockIndex = middlePage.height - targetHeight; + const targetOffset = (this.blockWidth * blockIndex) + left; + const deltaOffset = targetOffset - blockOffset; + if (this.pageIndex > 0) { + pages.push(this.getPageAt(this.pageIndex)); + } + pages.push(middlePage); + pages.push(this.getPageAt(middlePageIndex + 1)); + this.pages = pages; + this.blockchainContainer.nativeElement.scrollLeft += deltaOffset; + } else { + this.pageIndex = 0; + this.updatePages(); + } + } + + updatePages() { + const pages = []; + if (this.pageIndex > 0) { + pages.push(this.getPageAt(this.pageIndex)); + } + pages.push(this.getPageAt(this.pageIndex + 1)); + pages.push(this.getPageAt(this.pageIndex + 2)); + this.pages = pages; + } + + shiftPagesBack(): boolean { + this.pageIndex++; + this.pages.forEach(page => page.offset -= this.pageWidth); + if (this.pageIndex !== 1) { + this.pages.shift(); + } + this.pages.push(this.getPageAt(this.pageIndex + 2)); + return true; + } + + shiftPagesForward(): boolean { + if (this.pageIndex > 0) { + this.pageIndex--; + this.pages.forEach(page => page.offset += this.pageWidth); + this.pages.pop(); + if (this.pageIndex) { + this.pages.unshift(this.getPageAt(this.pageIndex)); + } + return true; + } + return false; + } + + getPageAt(index: number) { + return { + offset: this.firstPageWidth + (this.pageWidth * (index - 1 - this.pageIndex)), + height: this.chainTip - 8 - ((index - 1) * this.blocksPerPage), + }; + } + + getPageIndexOf(height: number): number { + const delta = this.chainTip - 8 - height; + return Math.max(0, Math.floor(delta / this.blocksPerPage) + 1); + } + ngOnDestroy() { this.timeLtrSubscription.unsubscribe(); } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 8a87b97e5..a15c992ad 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; -import { Transaction } from '../interfaces/electrs.interface'; +import { Block, Transaction } from '../interfaces/electrs.interface'; import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; @@ -104,6 +104,7 @@ export class StateService { backendInfo$ = new ReplaySubject(1); loadingIndicators$ = new ReplaySubject(1); recommendedFees$ = new ReplaySubject(1); + chainTip$ = new ReplaySubject(-1); live2Chart$ = new Subject(); @@ -111,7 +112,7 @@ export class StateService { connectionState$ = new BehaviorSubject<0 | 1 | 2>(2); isTabHidden$: Observable; - markBlock$ = new ReplaySubject(); + markBlock$ = new BehaviorSubject({}); keyNavigation$ = new Subject(); blockScrolling$: Subject = new Subject(); @@ -280,12 +281,25 @@ export class StateService { this.txCache[tx.txid] = tx; }); } - + getTxFromCache(txid) { if (this.txCache && this.txCache[txid]) { return this.txCache[txid]; } else { - return null; + return null; + } + } + + resetChainTip() { + this.latestBlockHeight = -1; + this.chainTip$.next(-1); + } + + updateChainTip(height) { + console.log('updating chain tip to ', height); + if (height > this.latestBlockHeight) { + this.latestBlockHeight = height; + this.chainTip$.next(height); } } } diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 7cb279a08..ffe094456 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -70,7 +70,7 @@ export class WebsocketService { clearTimeout(this.onlineCheckTimeout); clearTimeout(this.onlineCheckTimeoutTwo); - this.stateService.latestBlockHeight = -1; + this.stateService.resetChainTip(); this.websocketSubject.complete(); this.subscription.unsubscribe(); @@ -226,7 +226,7 @@ export class WebsocketService { const blocks = response.blocks; blocks.forEach((block: BlockExtended) => { if (block.height > this.stateService.latestBlockHeight) { - this.stateService.latestBlockHeight = block.height; + this.stateService.updateChainTip(block.height); this.stateService.blocks$.next([block, false]); } }); @@ -238,7 +238,7 @@ export class WebsocketService { if (response.block) { if (response.block.height > this.stateService.latestBlockHeight) { - this.stateService.latestBlockHeight = response.block.height; + this.stateService.updateChainTip(response.block.height); this.stateService.blocks$.next([response.block, !!response.txConfirmed]); }