From befafaa60c1b6e8362cc0e80bcc465e2eb8a7efe Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 27 Dec 2022 05:28:57 -0600 Subject: [PATCH 1/4] 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]); } From 7be3ed416ebda6a8f297c1104ce12cc24139517a Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 27 Dec 2022 05:36:58 -0600 Subject: [PATCH 2/4] create separate service for short term tx & block caching --- frontend/src/app/app.module.ts | 2 + .../app/components/block/block.component.ts | 1 - .../blockchain-blocks.component.ts | 30 ++++- .../app/components/start/start.component.ts | 5 + .../transaction-preview.component.ts | 4 +- .../transaction/transaction.component.ts | 6 +- .../transactions-list.component.ts | 4 +- frontend/src/app/services/cache.service.ts | 105 ++++++++++++++++++ frontend/src/app/services/state.service.ts | 20 +--- 9 files changed, 147 insertions(+), 30 deletions(-) create mode 100644 frontend/src/app/services/cache.service.ts diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 6ed7c43f9..b7bd1526f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -6,6 +6,7 @@ import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './components/app/app.component'; import { ElectrsApiService } from './services/electrs-api.service'; import { StateService } from './services/state.service'; +import { CacheService } from './services/cache.service'; import { EnterpriseService } from './services/enterprise.service'; import { WebsocketService } from './services/websocket.service'; import { AudioService } from './services/audio.service'; @@ -23,6 +24,7 @@ import { AppPreloadingStrategy } from './app.preloading-strategy'; const providers = [ ElectrsApiService, StateService, + CacheService, WebsocketService, AudioService, SeoService, diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index e92e44937..f04b4ec9c 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -138,7 +138,6 @@ export class BlockComponent implements OnInit, OnDestroy { this.page = 1; this.error = undefined; this.fees = undefined; - this.stateService.markBlock$.next({}); this.auditDataMissing = false; if (history.state.data && history.state.data.blockHeight) { 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 930fa2ea8..8972afcae 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -5,6 +5,7 @@ import { specialBlocks } from '../../app.constants'; import { BlockExtended } from '../../interfaces/node-api.interface'; import { Location } from '@angular/common'; import { config } from 'process'; +import { CacheService } from 'src/app/services/cache.service'; interface BlockchainBlock extends BlockExtended { loading?: boolean; @@ -28,6 +29,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { emptyBlocks: BlockExtended[] = this.mountEmptyBlocks(); markHeight: number; blocksSubscription: Subscription; + blockPageSubscription: Subscription; networkSubscription: Subscription; tabHiddenSubscription: Subscription; markBlockSubscription: Subscription; @@ -56,6 +58,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { constructor( public stateService: StateService, + public cacheService: CacheService, private cd: ChangeDetectorRef, private location: Location, ) { @@ -123,6 +126,12 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } this.cd.markForCheck(); }); + } else { + this.blockPageSubscription = this.cacheService.loadedBlocks$.subscribe((block) => { + if (block.height <= this.height && block.height > this.height - this.count) { + this.onBlockLoaded(block); + } + }); } this.markBlockSubscription = this.stateService.markBlock$ @@ -151,6 +160,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { if (this.blocksSubscription) { this.blocksSubscription.unsubscribe(); } + if (this.blockPageSubscription) { + this.blockPageSubscription.unsubscribe(); + } this.networkSubscription.unsubscribe(); this.tabHiddenSubscription.unsubscribe(); this.markBlockSubscription.unsubscribe(); @@ -201,12 +213,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { 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({ + this.cacheService.loadBlock(height); + const block = this.cacheService.getCachedBlock(height) || null; + this.blocks.push(block || { loading: true, id: '', height, @@ -236,6 +245,15 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } } + onBlockLoaded(block: BlockExtended) { + const blockIndex = this.height - block.height; + if (blockIndex >= 0 && blockIndex < this.blocks.length) { + this.blocks[blockIndex] = block; + this.blockStyles[blockIndex] = this.getStyleForBlock(block, blockIndex); + } + this.cd.markForCheck(); + } + getStyleForBlock(block: BlockchainBlock, index: number, animateSlideStart: boolean = false) { if (!block || block.loading) { return this.getStyleForLoadingBlock(index, animateSlideStart); diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index 5a70bf15c..ca8c2a47c 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -46,6 +46,11 @@ export class StartComponent implements OnInit, OnDestroy { this.chainTip = height; this.updatePages(); }); + this.markBlockSubscription = this.stateService.markBlock$.subscribe((mark) => { + if (mark?.blockHeight != null) { + this.scrollToBlock(mark.blockHeight); + } + }); this.stateService.blocks$ .subscribe((blocks: any) => { if (this.stateService.network !== '') { diff --git a/frontend/src/app/components/transaction/transaction-preview.component.ts b/frontend/src/app/components/transaction/transaction-preview.component.ts index 9d2d502b4..6db0e588c 100644 --- a/frontend/src/app/components/transaction/transaction-preview.component.ts +++ b/frontend/src/app/components/transaction/transaction-preview.component.ts @@ -11,6 +11,7 @@ import { import { Transaction, Vout } from '../../interfaces/electrs.interface'; import { of, merge, Subscription, Observable, Subject, from } from 'rxjs'; import { StateService } from '../../services/state.service'; +import { CacheService } from '../../services/cache.service'; import { OpenGraphService } from '../../services/opengraph.service'; import { ApiService } from '../../services/api.service'; import { SeoService } from '../../services/seo.service'; @@ -45,6 +46,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private electrsApiService: ElectrsApiService, private stateService: StateService, + private cacheService: CacheService, private apiService: ApiService, private seoService: SeoService, private openGraphService: OpenGraphService, @@ -97,7 +99,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { }), switchMap(() => { let transactionObservable$: Observable; - const cached = this.stateService.getTxFromCache(this.txId); + const cached = this.cacheService.getTxFromCache(this.txId); if (cached && cached.fee !== -1) { transactionObservable$ = of(cached); } else { diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 575c00637..1856ee48a 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -13,6 +13,7 @@ import { import { Transaction } from '../../interfaces/electrs.interface'; import { of, merge, Subscription, Observable, Subject, timer, combineLatest, from, throwError } from 'rxjs'; import { StateService } from '../../services/state.service'; +import { CacheService } from '../../services/cache.service'; import { WebsocketService } from '../../services/websocket.service'; import { AudioService } from '../../services/audio.service'; import { ApiService } from '../../services/api.service'; @@ -74,6 +75,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { private relativeUrlPipe: RelativeUrlPipe, private electrsApiService: ElectrsApiService, private stateService: StateService, + private cacheService: CacheService, private websocketService: WebsocketService, private audioService: AudioService, private apiService: ApiService, @@ -203,7 +205,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { }), switchMap(() => { let transactionObservable$: Observable; - const cached = this.stateService.getTxFromCache(this.txId); + const cached = this.cacheService.getTxFromCache(this.txId); if (cached && cached.fee !== -1) { transactionObservable$ = of(cached); } else { @@ -302,7 +304,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.waitingForTransaction = false; } this.rbfTransaction = rbfTransaction; - this.stateService.setTxCache([this.rbfTransaction]); + this.cacheService.setTxCache([this.rbfTransaction]); }); this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 8b4fabf6e..67df2daa2 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; import { StateService } from '../../services/state.service'; +import { CacheService } from '../../services/cache.service'; import { Observable, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs'; import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; @@ -44,6 +45,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { constructor( public stateService: StateService, + private cacheService: CacheService, private electrsApiService: ElectrsApiService, private apiService: ApiService, private assetsService: AssetsService, @@ -123,7 +125,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { } this.transactionsLength = this.transactions.length; - this.stateService.setTxCache(this.transactions); + this.cacheService.setTxCache(this.transactions); this.transactions.forEach((tx) => { tx['@voutLimit'] = true; diff --git a/frontend/src/app/services/cache.service.ts b/frontend/src/app/services/cache.service.ts new file mode 100644 index 000000000..5e184c184 --- /dev/null +++ b/frontend/src/app/services/cache.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@angular/core'; +import { firstValueFrom, Subject, Subscription} from 'rxjs'; +import { Transaction } from '../interfaces/electrs.interface'; +import { BlockExtended } from '../interfaces/node-api.interface'; +import { StateService } from './state.service'; +import { ApiService } from './api.service'; + +const BLOCK_CACHE_SIZE = 50; +const KEEP_RECENT_BLOCKS = 50; + +@Injectable({ + providedIn: 'root' +}) +export class CacheService { + loadedBlocks$ = new Subject(); + tip: number = 0; + + txCache: { [txid: string]: Transaction } = {}; + + blockCache: { [height: number]: BlockExtended } = {}; + blockLoading: { [height: number]: boolean } = {}; + copiesInBlockQueue: { [height: number]: number } = {}; + blockPriorities: number[] = []; + + constructor( + private stateService: StateService, + private apiService: ApiService, + ) { + this.stateService.blocks$.subscribe(([block]) => { + this.addBlockToCache(block); + this.clearBlocks(); + }); + this.stateService.chainTip$.subscribe((height) => { + this.tip = height; + }); + } + + setTxCache(transactions) { + this.txCache = {}; + transactions.forEach(tx => { + this.txCache[tx.txid] = tx; + }); + } + + getTxFromCache(txid) { + if (this.txCache && this.txCache[txid]) { + return this.txCache[txid]; + } else { + return null; + } + } + + addBlockToCache(block: BlockExtended) { + this.blockCache[block.height] = block; + this.bumpBlockPriority(block.height); + } + + async loadBlock(height) { + if (!this.blockCache[height] && !this.blockLoading[height]) { + const chunkSize = 10; + const maxHeight = Math.ceil(height / chunkSize) * chunkSize; + for (let i = 0; i < chunkSize; i++) { + this.blockLoading[maxHeight - i] = true; + } + const result = await firstValueFrom(this.apiService.getBlocks$(maxHeight)); + for (let i = 0; i < chunkSize; i++) { + delete this.blockLoading[maxHeight - i]; + } + if (result && result.length) { + result.forEach(block => { + this.addBlockToCache(block); + this.loadedBlocks$.next(block); + }); + } + this.clearBlocks(); + } else { + this.bumpBlockPriority(height); + } + } + + // increase the priority of a block, to delay removal + bumpBlockPriority(height) { + this.blockPriorities.push(height); + this.copiesInBlockQueue[height] = (this.copiesInBlockQueue[height] || 0) + 1; + } + + // remove lowest priority blocks from the cache + clearBlocks() { + while (Object.keys(this.blockCache).length > (BLOCK_CACHE_SIZE + KEEP_RECENT_BLOCKS) && this.blockPriorities.length > KEEP_RECENT_BLOCKS) { + const height = this.blockPriorities.shift(); + if (this.copiesInBlockQueue[height] > 1) { + this.copiesInBlockQueue[height]--; + } else if ((this.tip - height) < KEEP_RECENT_BLOCKS) { + this.bumpBlockPriority(height); + } else { + delete this.blockCache[height]; + delete this.copiesInBlockQueue[height]; + } + } + } + + getCachedBlock(height) { + return this.blockCache[height]; + } +} \ No newline at end of file diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index a15c992ad..86efa57f8 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 { Block, Transaction } from '../interfaces/electrs.interface'; +import { 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'; @@ -119,8 +119,6 @@ export class StateService { timeLtr: BehaviorSubject; hideFlow: BehaviorSubject; - txCache: { [txid: string]: Transaction } = {}; - constructor( @Inject(PLATFORM_ID) private platformId: any, @Inject(LOCALE_ID) private locale: string, @@ -275,28 +273,12 @@ export class StateService { return this.network === 'liquid' || this.network === 'liquidtestnet'; } - setTxCache(transactions) { - this.txCache = {}; - transactions.forEach(tx => { - this.txCache[tx.txid] = tx; - }); - } - - getTxFromCache(txid) { - if (this.txCache && this.txCache[txid]) { - return this.txCache[txid]; - } else { - 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); From 32bf30872dea540d5d2718c34c4cbb1498ecb4a7 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 27 Dec 2022 11:33:08 -0600 Subject: [PATCH 3/4] improve block scrolling & new block animation --- .../blockchain-blocks.component.html | 30 ++-- .../blockchain-blocks.component.scss | 14 ++ .../blockchain-blocks.component.ts | 96 ++++++------ .../blockchain/blockchain.component.ts | 4 +- .../app/components/start/start.component.ts | 148 +++++++++++------- .../src/app/interfaces/node-api.interface.ts | 2 - 6 files changed, 180 insertions(+), 114 deletions(-) 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 9a305833f..b27dab69d 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html @@ -1,6 +1,6 @@
-
- +
+
  @@ -11,8 +11,11 @@
~{{ 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 +
+ {{ block?.extras?.feeRange?.[1] | number:feeRounding }} - {{ block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} sat/vB +
+
+  
@@ -31,16 +34,19 @@
+ + +
+ +
+
+
-
-   -
- {{ block.height }} + +
+
-
- loading -
-
+
diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss index adde4a945..64bfd2379 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss @@ -25,6 +25,10 @@ transition: background 2s, left 2s, transform 1s; } +.mined-block.placeholder-block { + background: none !important; +} + .block-size { font-size: 16px; font-weight: bold; @@ -96,6 +100,16 @@ transform-origin: top; } +.bitcoin-block.placeholder-block::after { + content: none; + background: 0; +} + +.bitcoin-block.placeholder-block::before { + content: none; + background: 0; +} + .black-background { background-color: #11131f; z-index: 100; 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 8972afcae..df583b0af 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -8,6 +8,7 @@ import { config } from 'process'; import { CacheService } from 'src/app/services/cache.service'; interface BlockchainBlock extends BlockExtended { + placeholder?: boolean; loading?: boolean; } @@ -102,10 +103,6 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { 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 (txConfirmed) { this.markHeight = block.height; this.moveArrowToPosition(true, true); @@ -114,12 +111,16 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } this.blockStyles = []; - this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i))); - setTimeout(() => { - this.blockStyles = []; + if (this.blocksFilled) { + this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, i ? -155 : -205))); + setTimeout(() => { + this.blockStyles = []; + this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i))); + this.cd.markForCheck(); + }, 50); + } else { 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; @@ -210,31 +211,34 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { // reset blocks this.blocks = []; this.blockStyles = []; - while (this.blocks.length < Math.min(this.height + 1, this.count)) { + while (this.blocks.length < this.count) { const height = this.height - this.blocks.length; + let block; if (height >= 0) { this.cacheService.loadBlock(height); - const block = this.cacheService.getCachedBlock(height) || null; - this.blocks.push(block || { - loading: true, - id: '', - height, - version: 0, - timestamp: 0, - bits: 0, - nonce: 0, - difficulty: 0, - merkle_root: '', - tx_count: 0, - size: 0, - weight: 0, - previousblockhash: '', - }); + block = this.cacheService.getCachedBlock(height) || null; } + this.blocks.push(block || { + placeholder: height < 0, + loading: height >= 0, + 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))); + this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, animateSlide ? -155 : 0))); + this.cd.markForCheck(); if (animateSlide) { // animate blocks slide right setTimeout(() => { @@ -242,6 +246,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i))); this.cd.markForCheck(); }, 50); + this.moveArrowToPosition(true, true); + } else { + this.moveArrowToPosition(false, false); } } @@ -254,18 +261,17 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.cd.markForCheck(); } - getStyleForBlock(block: BlockchainBlock, index: number, animateSlideStart: boolean = false) { - if (!block || block.loading) { - return this.getStyleForLoadingBlock(index, animateSlideStart); + getStyleForBlock(block: BlockchainBlock, index: number, animateEnterFrom: number = 0) { + if (!block || block.placeholder) { + return this.getStyleForPlaceholderBlock(index, animateEnterFrom); + } else if (block.loading) { + return this.getStyleForLoadingBlock(index, animateEnterFrom); } const greenBackgroundHeight = 100 - (block.weight / this.stateService.env.BLOCK_WEIGHT_UNITS) * 100; let addLeft = 0; - if (animateSlideStart) { - if (block?.extras) { - block.extras.stage = 2; - } - addLeft = -205; + if (animateEnterFrom) { + addLeft = animateEnterFrom || 0; } return { @@ -276,11 +282,12 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { ${this.gradientColors[this.network][0]} ${Math.max(greenBackgroundHeight, 0)}%, ${this.gradientColors[this.network][1]} 100% )`, + transition: animateEnterFrom ? 'background 2s, transform 1s' : null, }; } - getStyleForLoadingBlock(index: number, animateSlideStart: boolean = false) { - const addLeft = animateSlideStart ? -205 : 0; + getStyleForLoadingBlock(index: number, animateEnterFrom: number = 0) { + const addLeft = animateEnterFrom || 0; return { left: addLeft + (155 * index) + 'px', @@ -288,13 +295,15 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { }; } - getStyleForEmptyBlock(block: BlockExtended) { - let addLeft = 0; + getStyleForPlaceholderBlock(index: number, animateEnterFrom: number = 0) { + const addLeft = animateEnterFrom || 0; + return { + left: addLeft + (155 * index) + 'px', + }; + } - if (block?.extras?.stage === 1) { - block.extras.stage = 2; - addLeft = -205; - } + getStyleForEmptyBlock(block: BlockExtended, animateEnterFrom: number = 0) { + const addLeft = animateEnterFrom || 0; return { left: addLeft + 155 * this.emptyBlocks.indexOf(block) + 'px', @@ -319,7 +328,6 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { weight: 0, previousblockhash: '', matchRate: 0, - stage: 0, }); } return emptyBlocks; diff --git a/frontend/src/app/components/blockchain/blockchain.component.ts b/frontend/src/app/components/blockchain/blockchain.component.ts index c25ebdafd..cd09b7430 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.ts +++ b/frontend/src/app/components/blockchain/blockchain.component.ts @@ -33,8 +33,8 @@ export class BlockchainComponent implements OnInit, OnDestroy { this.timeLtrSubscription.unsubscribe(); } - trackByPageFn(index: number, item: { height: number }) { - return item.height; + trackByPageFn(index: number, item: { index: number }) { + return item.index; } toggleTimeDirection() { diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index ca8c2a47c..d4b3a6f67 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -47,7 +47,7 @@ export class StartComponent implements OnInit, OnDestroy { this.updatePages(); }); this.markBlockSubscription = this.stateService.markBlock$.subscribe((mark) => { - if (mark?.blockHeight != null) { + if (mark?.blockHeight != null && !this.blockInViewport(mark.blockHeight)) { this.scrollToBlock(mark.blockHeight); } }); @@ -82,15 +82,17 @@ export class StartComponent implements OnInit, OnDestroy { 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); - } - }); + if (this.blockchainContainer?.nativeElement != null) { + this.pages.forEach(page => { + const left = page.offset - this.getConvertedScrollOffset(); + 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; @@ -132,25 +134,16 @@ export class StartComponent implements OnInit, OnDestroy { 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; - } + const scrollLeft = this.getConvertedScrollOffset(); + if (scrollLeft > backThreshold) { + if (this.shiftPagesBack()) { + this.addConvertedScrollOffset(-this.pageWidth); + this.blockchainScrollLeftInit -= 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; - } + } else if (scrollLeft < forwardThreshold) { + if (this.shiftPagesForward()) { + this.addConvertedScrollOffset(this.pageWidth); + this.blockchainScrollLeftInit += this.pageWidth; } } } @@ -160,27 +153,39 @@ export class StartComponent implements OnInit, OnDestroy { setTimeout(() => { this.scrollToBlock(height, blockOffset); }, 50); return; } - let targetHeight = this.isMobile ? height - 1 : height; - const middlePageIndex = this.getPageIndexOf(targetHeight); + const targetHeight = this.isMobile ? height - 1 : height; + const viewingPageIndex = 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(); + this.pageIndex = Math.max(viewingPageIndex - 1, 0); + let viewingPage = this.getPageAt(viewingPageIndex); + const isLastPage = viewingPage.height < this.blocksPerPage; + if (isLastPage) { + this.pageIndex = Math.max(viewingPageIndex - 2, 0); + viewingPage = this.getPageAt(viewingPageIndex); } + const left = viewingPage.offset - this.getConvertedScrollOffset(); + const blockIndex = viewingPage.height - targetHeight; + const targetOffset = (this.blockWidth * blockIndex) + left; + let deltaOffset = targetOffset - blockOffset; + + if (isLastPage) { + pages.push(this.getPageAt(viewingPageIndex - 2)); + } + if (viewingPageIndex > 1) { + pages.push(this.getPageAt(viewingPageIndex - 1)); + } + if (viewingPageIndex > 0) { + pages.push(viewingPage); + } + if (!isLastPage) { + pages.push(this.getPageAt(viewingPageIndex + 1)); + } + if (viewingPageIndex === 0) { + pages.push(this.getPageAt(viewingPageIndex + 2)); + } + + this.pages = pages; + this.addConvertedScrollOffset(deltaOffset); } updatePages() { @@ -194,13 +199,18 @@ export class StartComponent implements OnInit, OnDestroy { } shiftPagesBack(): boolean { - this.pageIndex++; - this.pages.forEach(page => page.offset -= this.pageWidth); - if (this.pageIndex !== 1) { - this.pages.shift(); + const nextPage = this.getPageAt(this.pageIndex + 3); + if (nextPage.height >= 0) { + 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; + } else { + return false; } - this.pages.push(this.getPageAt(this.pageIndex + 2)); - return true; } shiftPagesForward(): boolean { @@ -217,9 +227,12 @@ export class StartComponent implements OnInit, OnDestroy { } getPageAt(index: number) { + const height = this.chainTip - 8 - ((index - 1) * this.blocksPerPage) return { offset: this.firstPageWidth + (this.pageWidth * (index - 1 - this.pageIndex)), - height: this.chainTip - 8 - ((index - 1) * this.blocksPerPage), + height: height, + depth: this.chainTip - height, + index: index, }; } @@ -228,6 +241,33 @@ export class StartComponent implements OnInit, OnDestroy { return Math.max(0, Math.floor(delta / this.blocksPerPage) + 1); } + blockInViewport(height: number): boolean { + const firstHeight = this.pages[0].height; + const translation = (this.isMobile ? window.innerWidth * 0.95 : window.innerWidth * 0.5); + const firstX = this.pages[0].offset - this.getConvertedScrollOffset() + translation; + const xPos = firstX + ((firstHeight - height) * 155); + return xPos > -55 && xPos < (window.innerWidth - 100); + } + + getConvertedScrollOffset(): number { + if (this.timeLtr) { + return -this.blockchainContainer?.nativeElement?.scrollLeft || 0; + } else { + return this.blockchainContainer?.nativeElement?.scrollLeft || 0; + } + } + + addConvertedScrollOffset(offset: number): void { + if (!this.blockchainContainer?.nativeElement) { + return; + } + if (this.timeLtr) { + this.blockchainContainer.nativeElement.scrollLeft -= offset; + } else { + this.blockchainContainer.nativeElement.scrollLeft += offset; + } + } + ngOnDestroy() { this.timeLtrSubscription.unsubscribe(); } diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 2e6b94988..b0045218e 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -122,8 +122,6 @@ export interface BlockExtension { name: string; slug: string; } - - stage?: number; // Frontend only } export interface BlockExtended extends Block { From bf941b0227ed5a804d7b397fffed4d8813c8679f Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 28 Dec 2022 06:05:46 -0600 Subject: [PATCH 4/4] load block/tx pages at correct blockchain scroll position --- .../blockchain-blocks.component.html | 2 +- .../blockchain-blocks.component.ts | 10 +++++----- .../blockchain/blockchain.component.html | 1 + .../blockchain/blockchain.component.scss | 9 +++++++++ .../blockchain/blockchain.component.ts | 1 + .../app/components/start/start.component.html | 2 +- .../app/components/start/start.component.ts | 20 +++++++++++++++++-- frontend/src/app/services/cache.service.ts | 2 +- .../src/app/services/websocket.service.ts | 4 +++- 9 files changed, 40 insertions(+), 11 deletions(-) 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 b27dab69d..29df378a4 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html @@ -49,7 +49,7 @@
-
+
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 df583b0af..fd8819a6f 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -43,7 +43,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { arrowVisible = false; arrowLeftPx = 30; blocksFilled = false; - transition = '1s'; + arrowTransition = '1s'; showMiningInfo = false; timeLtrSubscription: Subscription; timeLtr: boolean; @@ -179,13 +179,13 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { const blockindex = this.blocks.findIndex((b) => b.height === this.markHeight); if (blockindex > -1) { if (!animate) { - this.transition = 'inherit'; + this.arrowTransition = 'inherit'; } this.arrowVisible = true; if (newBlockFromLeft) { this.arrowLeftPx = blockindex * 155 + 30 - 205; setTimeout(() => { - this.transition = '2s'; + this.arrowTransition = '2s'; this.arrowLeftPx = blockindex * 155 + 30; this.cd.markForCheck(); }, 50); @@ -193,9 +193,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.arrowLeftPx = blockindex * 155 + 30; if (!animate) { setTimeout(() => { - this.transition = '2s'; + this.arrowTransition = '2s'; this.cd.markForCheck(); - }); + }, 50); } } } else { diff --git a/frontend/src/app/components/blockchain/blockchain.component.html b/frontend/src/app/components/blockchain/blockchain.component.html index 542a837ea..ad2e5e86a 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.html +++ b/frontend/src/app/components/blockchain/blockchain.component.html @@ -2,6 +2,7 @@
+
diff --git a/frontend/src/app/components/blockchain/blockchain.component.scss b/frontend/src/app/components/blockchain/blockchain.component.scss index df609ff40..63ca22626 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.scss +++ b/frontend/src/app/components/blockchain/blockchain.component.scss @@ -72,6 +72,15 @@ position: relative; } +.scroll-spacer { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + pointer-events: none; +} + .loading-block { position: absolute; text-align: center; diff --git a/frontend/src/app/components/blockchain/blockchain.component.ts b/frontend/src/app/components/blockchain/blockchain.component.ts index cd09b7430..0ad3625ea 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.ts +++ b/frontend/src/app/components/blockchain/blockchain.component.ts @@ -12,6 +12,7 @@ export class BlockchainComponent implements OnInit, OnDestroy { @Input() pages: any[] = []; @Input() pageIndex: number; @Input() blocksPerPage: number = 8; + @Input() minScrollWidth: number = 0; network: string; timeLtrSubscription: Subscription; diff --git a/frontend/src/app/components/start/start.component.html b/frontend/src/app/components/start/start.component.html index ae4c213cd..c3277cb9a 100644 --- a/frontend/src/app/components/start/start.component.html +++ b/frontend/src/app/components/start/start.component.html @@ -13,7 +13,7 @@ (dragstart)="onDragStart($event)" (scroll)="onScroll($event)" > - +
diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index d4b3a6f67..558e6f909 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -29,8 +29,10 @@ export class StartComponent implements OnInit, OnDestroy { blocksPerPage: number = 1; pageWidth: number; firstPageWidth: number; + minScrollWidth: number; pageIndex: number = 0; pages: any[] = []; + pendingMark: number | void = null; constructor( private stateService: StateService, @@ -39,16 +41,27 @@ export class StartComponent implements OnInit, OnDestroy { ngOnInit() { this.firstPageWidth = 40 + (this.blockWidth * this.stateService.env.KEEP_BLOCKS_AMOUNT); this.onResize(); + this.updatePages(); this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { this.timeLtr = !!ltr; }); this.chainTipSubscription = this.stateService.chainTip$.subscribe((height) => { this.chainTip = height; this.updatePages(); + if (this.pendingMark != null) { + this.scrollToBlock(this.pendingMark); + this.pendingMark = null; + } }); this.markBlockSubscription = this.stateService.markBlock$.subscribe((mark) => { - if (mark?.blockHeight != null && !this.blockInViewport(mark.blockHeight)) { - this.scrollToBlock(mark.blockHeight); + if (mark?.blockHeight != null) { + if (this.chainTip >=0) { + if (!this.blockInViewport(mark.blockHeight)) { + this.scrollToBlock(mark.blockHeight); + } + } else { + this.pendingMark = mark.blockHeight; + } } }); this.stateService.blocks$ @@ -96,6 +109,7 @@ export class StartComponent implements OnInit, OnDestroy { this.blocksPerPage = Math.ceil(window.innerWidth / this.blockWidth); this.pageWidth = this.blocksPerPage * this.blockWidth; + this.minScrollWidth = this.firstPageWidth + (this.pageWidth * 2); if (firstVisibleBlock != null) { this.scrollToBlock(firstVisibleBlock, offset); @@ -270,5 +284,7 @@ export class StartComponent implements OnInit, OnDestroy { ngOnDestroy() { this.timeLtrSubscription.unsubscribe(); + this.chainTipSubscription.unsubscribe(); + this.markBlockSubscription.unsubscribe(); } } diff --git a/frontend/src/app/services/cache.service.ts b/frontend/src/app/services/cache.service.ts index 5e184c184..be37164dd 100644 --- a/frontend/src/app/services/cache.service.ts +++ b/frontend/src/app/services/cache.service.ts @@ -5,7 +5,7 @@ import { BlockExtended } from '../interfaces/node-api.interface'; import { StateService } from './state.service'; import { ApiService } from './api.service'; -const BLOCK_CACHE_SIZE = 50; +const BLOCK_CACHE_SIZE = 500; const KEEP_RECENT_BLOCKS = 50; @Injectable({ diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index ffe094456..d58ab58c9 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -224,12 +224,14 @@ export class WebsocketService { handleResponse(response: WebsocketResponse) { if (response.blocks && response.blocks.length) { const blocks = response.blocks; + let maxHeight = 0; blocks.forEach((block: BlockExtended) => { if (block.height > this.stateService.latestBlockHeight) { - this.stateService.updateChainTip(block.height); + maxHeight = Math.max(maxHeight, block.height); this.stateService.blocks$.next([block, false]); } }); + this.stateService.updateChainTip(maxHeight); } if (response.tx) {