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 873a4653f..83de897ca 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -683,7 +683,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/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/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/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.html b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html index 6bd617435..29df378a4 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html @@ -1,36 +1,55 @@ -
-
-
-   -
- {{ 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?.extras?.feeRange[1] | number:feeRounding }} - {{ block?.extras?.feeRange[block?.extras?.feeRange.length - 1] | number:feeRounding }} sat/vB + + + + +
+
-
- -
-
-
- - {{ i }} transaction - {{ i }} transactions -
-
-
- -
+ +
-
+
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 8ac925eaf..fd8819a6f 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -1,10 +1,16 @@ -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'; 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 { + placeholder?: boolean; + loading?: boolean; +} @Component({ selector: 'app-blockchain-blocks', @@ -12,13 +18,19 @@ import { config } from 'process'; 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; + blockPageSubscription: Subscription; networkSubscription: Subscription; tabHiddenSubscription: Subscription; markBlockSubscription: Subscription; @@ -31,7 +43,7 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { arrowVisible = false; arrowLeftPx = 30; blocksFilled = false; - transition = '1s'; + arrowTransition = '1s'; showMiningInfo = false; timeLtrSubscription: Subscription; timeLtr: boolean; @@ -47,6 +59,7 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { constructor( public stateService: StateService, + public cacheService: CacheService, private cd: ChangeDetectorRef, private location: Location, ) { @@ -75,44 +88,52 @@ 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 (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); + 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))); + } - if (this.blocks.length === this.stateService.env.KEEP_BLOCKS_AMOUNT) { - this.blocksFilled = true; + if (this.blocks.length === this.stateService.env.KEEP_BLOCKS_AMOUNT) { + this.blocksFilled = true; + } + 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.cd.markForCheck(); }); + } this.markBlockSubscription = this.stateService.markBlock$ .subscribe((state) => { @@ -123,10 +144,26 @@ 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(); + } + if (this.blockPageSubscription) { + this.blockPageSubscription.unsubscribe(); + } this.networkSubscription.unsubscribe(); this.tabHiddenSubscription.unsubscribe(); this.markBlockSubscription.unsubscribe(); @@ -142,13 +179,13 @@ export class BlockchainBlocksComponent implements OnInit, 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); @@ -156,45 +193,117 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { this.arrowLeftPx = blockindex * 155 + 30; if (!animate) { setTimeout(() => { - this.transition = '2s'; + this.arrowTransition = '2s'; this.cd.markForCheck(); - }); + }, 50); } } + } 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 < this.count) { + const height = this.height - this.blocks.length; + let block; + if (height >= 0) { + this.cacheService.loadBlock(height); + 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 ? -155 : 0))); + this.cd.markForCheck(); + 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); + this.moveArrowToPosition(true, true); + } else { + this.moveArrowToPosition(false, false); + } + } + + 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, 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 (block?.extras?.stage === 1) { - block.extras.stage = 2; - addLeft = -205; + if (animateEnterFrom) { + addLeft = animateEnterFrom || 0; } return { - left: addLeft + 155 * this.blocks.indexOf(block) + 'px', + left: addLeft + 155 * index + 'px', background: `repeating-linear-gradient( #2d3348, #2d3348 ${greenBackgroundHeight}%, ${this.gradientColors[this.network][0]} ${Math.max(greenBackgroundHeight, 0)}%, ${this.gradientColors[this.network][1]} 100% )`, + transition: animateEnterFrom ? 'background 2s, transform 1s' : null, }; } - getStyleForEmptyBlock(block: BlockExtended) { - let addLeft = 0; + getStyleForLoadingBlock(index: number, animateEnterFrom: number = 0) { + const addLeft = animateEnterFrom || 0; - if (block?.extras?.stage === 1) { - block.extras.stage = 2; - addLeft = -205; - } + return { + left: addLeft + (155 * index) + 'px', + background: "#2d3348", + }; + } + + getStyleForPlaceholderBlock(index: number, animateEnterFrom: number = 0) { + const addLeft = animateEnterFrom || 0; + return { + left: addLeft + (155 * index) + 'px', + }; + } + + getStyleForEmptyBlock(block: BlockExtended, animateEnterFrom: number = 0) { + const addLeft = animateEnterFrom || 0; return { left: addLeft + 155 * this.emptyBlocks.indexOf(block) + 'px', @@ -219,7 +328,6 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { weight: 0, previousblockhash: '', matchRate: 0, - stage: 0, }); } return emptyBlocks; diff --git a/frontend/src/app/components/blockchain/blockchain.component.html b/frontend/src/app/components/blockchain/blockchain.component.html index 66ae8dd43..ad2e5e86a 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.html +++ b/frontend/src/app/components/blockchain/blockchain.component.html @@ -2,10 +2,14 @@
- - +
+ + + + +
-
+
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 e99b3532d..0ad3625ea 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,11 @@ 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; + @Input() minScrollWidth: number = 0; + network: string; timeLtrSubscription: Subscription; timeLtr: boolean = this.stateService.timeLtr.value; @@ -29,6 +34,10 @@ export class BlockchainComponent implements OnInit, OnDestroy { this.timeLtrSubscription.unsubscribe(); } + trackByPageFn(index: number, item: { index: number }) { + return item.index; + } + 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..c3277cb9a 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..558e6f909 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -19,16 +19,51 @@ 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; + minScrollWidth: number; + pageIndex: number = 0; + pages: any[] = []; + pendingMark: number | void = null; + constructor( private stateService: StateService, ) { } 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) { + if (this.chainTip >=0) { + if (!this.blockInViewport(mark.blockHeight)) { + this.scrollToBlock(mark.blockHeight); + } + } else { + this.pendingMark = mark.blockHeight; + } + } + }); this.stateService.blocks$ .subscribe((blocks: any) => { if (this.stateService.network !== '') { @@ -55,6 +90,34 @@ export class StartComponent implements OnInit, OnDestroy { }); } + @HostListener('window:resize', ['$event']) + onResize(): void { + this.isMobile = window.innerWidth <= 767.98; + let firstVisibleBlock; + let offset; + 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; + this.minScrollWidth = this.firstPageWidth + (this.pageWidth * 2); + + 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 +133,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,7 +142,149 @@ 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; + const scrollLeft = this.getConvertedScrollOffset(); + if (scrollLeft > backThreshold) { + if (this.shiftPagesBack()) { + this.addConvertedScrollOffset(-this.pageWidth); + this.blockchainScrollLeftInit -= this.pageWidth; + } + } else if (scrollLeft < forwardThreshold) { + if (this.shiftPagesForward()) { + this.addConvertedScrollOffset(this.pageWidth); + this.blockchainScrollLeftInit += this.pageWidth; + } + } + } + + scrollToBlock(height, blockOffset = 0) { + if (!this.blockchainContainer?.nativeElement) { + setTimeout(() => { this.scrollToBlock(height, blockOffset); }, 50); + return; + } + const targetHeight = this.isMobile ? height - 1 : height; + const viewingPageIndex = this.getPageIndexOf(targetHeight); + const pages = []; + 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() { + 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 { + 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; + } + } + + 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) { + const height = this.chainTip - 8 - ((index - 1) * this.blocksPerPage) + return { + offset: this.firstPageWidth + (this.pageWidth * (index - 1 - this.pageIndex)), + height: height, + depth: this.chainTip - height, + index: index, + }; + } + + getPageIndexOf(height: number): number { + const delta = this.chainTip - 8 - height; + 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(); + this.chainTipSubscription.unsubscribe(); + this.markBlockSubscription.unsubscribe(); } } 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 e01092189..cd85d0f4f 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, @@ -197,7 +199,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 { @@ -296,7 +298,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/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index f72886870..c35eb8098 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -121,8 +121,6 @@ export interface BlockExtension { name: string; slug: string; } - - stage?: number; // Frontend only } export interface BlockExtended extends Block { diff --git a/frontend/src/app/services/cache.service.ts b/frontend/src/app/services/cache.service.ts new file mode 100644 index 000000000..be37164dd --- /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 = 500; +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 8a87b97e5..86efa57f8 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -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,15 +112,13 @@ 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(); timeLtr: BehaviorSubject; hideFlow: BehaviorSubject; - txCache: { [txid: string]: Transaction } = {}; - constructor( @Inject(PLATFORM_ID) private platformId: any, @Inject(LOCALE_ID) private locale: string, @@ -274,18 +273,15 @@ export class StateService { return this.network === 'liquid' || this.network === 'liquidtestnet'; } - setTxCache(transactions) { - this.txCache = {}; - transactions.forEach(tx => { - this.txCache[tx.txid] = tx; - }); + resetChainTip() { + this.latestBlockHeight = -1; + this.chainTip$.next(-1); } - - getTxFromCache(txid) { - if (this.txCache && this.txCache[txid]) { - return this.txCache[txid]; - } else { - return null; + + updateChainTip(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..d58ab58c9 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(); @@ -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.latestBlockHeight = block.height; + maxHeight = Math.max(maxHeight, block.height); this.stateService.blocks$.next([block, false]); } }); + this.stateService.updateChainTip(maxHeight); } if (response.tx) { @@ -238,7 +240,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]); }