import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, Input, OnChanges, SimpleChanges } from '@angular/core'; import { Observable, Subscription, delay, filter, tap } 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 { CacheService } from '../../services/cache.service'; interface BlockchainBlock extends BlockExtended { placeholder?: boolean; 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, OnChanges, OnDestroy { @Input() static: boolean = false; @Input() offset: number = 0; @Input() height: number = 0; // max height of blocks in chunk (dynamic blocks only) @Input() count: number = 8; // number of blocks in this chunk (dynamic blocks only) @Input() loadingTip: boolean = false; @Input() connected: boolean = true; @Input() minimal: boolean = false; @Input() blockWidth: number = 125; @Input() spotlight: number = 0; @Input() showPools: boolean = true; @Input() getHref?: (index, block) => string = (index, block) => `/block/${block.id}`; specialBlocks = specialBlocks; network = ''; blocks: BlockchainBlock[] = []; dynamicBlocksAmount: number = 8; emptyBlocks: BlockExtended[] = this.mountEmptyBlocks(); markHeight: number; chainTip: number; pendingMarkBlock: { animate: boolean, newBlockFromLeft: boolean }; blocksSubscription: Subscription; blockPageSubscription: Subscription; networkSubscription: Subscription; tabHiddenSubscription: Subscription; markBlockSubscription: Subscription; txConfirmedSubscription: Subscription; loadingBlocks$: Observable; showMiningInfoSubscription: Subscription; blockDisplayModeSubscription: Subscription; blockDisplayMode: 'size' | 'fees'; blockTransformation = {}; blockStyles = []; emptyBlockStyles = []; interval: any; tabHidden = false; feeRounding = '1.0-0'; arrowVisible = false; arrowLeftPx = 30; blocksFilled = false; arrowTransition = '1s'; timeLtrSubscription: Subscription; timeLtr: boolean; blockOffset: number = 155; dividerBlockOffset: number = 205; blockPadding: number = 30; gradientColors = { '': ['var(--mainnet-alt)', 'var(--primary)'], liquid: ['var(--liquid)', 'var(--testnet-alt)'], 'liquidtestnet': ['var(--liquidtestnet)', 'var(--liquidtestnet-alt)'], testnet: ['var(--testnet)', 'var(--testnet-alt)'], testnet4: ['var(--testnet)', 'var(--testnet-alt)'], signet: ['var(--signet)', 'var(--signet-alt)'], }; constructor( public stateService: StateService, public cacheService: CacheService, private cd: ChangeDetectorRef, private location: Location, ) { } ngOnInit() { this.dynamicBlocksAmount = Math.min(8, this.stateService.env.KEEP_BLOCKS_AMOUNT); this.blockDisplayMode = this.stateService.blockDisplayMode$.value as 'size' | 'fees'; this.blockDisplayModeSubscription = this.stateService.blockDisplayMode$ .pipe( filter((mode: 'size' | 'fees') => mode !== this.blockDisplayMode), tap(() => { this.blockTransformation = this.timeLtr ? { transform: 'scaleX(-1) rotateX(90deg)', transition: 'transform 0.375s' } : { transform: 'rotateX(90deg)', transition: 'transform 0.375s' }; }), delay(375), tap((mode) => { this.blockDisplayMode = mode; this.blockTransformation = this.timeLtr ? { transform: 'scaleX(-1)', transition: 'transform 0.375s' } : { transition: 'transform 0.375s' }; this.cd.markForCheck(); }), delay(375), ) .subscribe(() => { this.blockTransformation = {}; }); this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { this.timeLtr = !!ltr; this.cd.markForCheck(); }); if (this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') { this.feeRounding = '1.0-1'; } this.emptyBlocks.forEach((b) => this.emptyBlockStyles.push(this.getStyleForEmptyBlock(b))); 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); if (!this.static) { this.blocksSubscription = this.stateService.blocks$ .subscribe((blocks) => { if (!blocks?.length) { return; } const latestHeight = blocks[0].height; const animate = this.chainTip != null && latestHeight > this.chainTip; for (const block of blocks) { if (block?.extras) { block.extras.minFee = this.getMinBlockFee(block); block.extras.maxFee = this.getMaxBlockFee(block); } } this.blocks = blocks; this.blockStyles = []; if (animate) { this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, i ? -this.blockOffset : -this.dividerBlockOffset))); 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.chainTip = latestHeight; if (this.pendingMarkBlock) { this.moveArrowToPosition(this.pendingMarkBlock.animate, this.pendingMarkBlock.newBlockFromLeft); this.pendingMarkBlock = null; } this.cd.markForCheck(); }); this.txConfirmedSubscription = this.stateService.txConfirmed$.subscribe(([txid, block]) => { if (txid) { this.markHeight = block.height; this.moveArrowToPosition(true, true); } else { this.moveArrowToPosition(true, false); } }) } 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$ .subscribe((state) => { this.markHeight = undefined; if (state.blockHeight !== undefined) { this.markHeight = state.blockHeight; } this.moveArrowToPosition(false); this.cd.markForCheck(); }); if (this.static) { this.updateStaticBlocks(); } } ngOnChanges(changes: SimpleChanges): void { if (changes.blockWidth && this.blockWidth) { this.blockPadding = 0.24 * this.blockWidth; this.blockOffset = this.blockWidth + this.blockPadding; this.dividerBlockOffset = this.blockOffset + (0.4 * this.blockWidth); this.blockStyles = []; this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i))); } if (this.static) { const animateSlide = changes.height && (changes.height.currentValue === changes.height.previousValue + 1); this.updateStaticBlocks(animateSlide); } } ngOnDestroy() { if (this.blocksSubscription) { this.blocksSubscription.unsubscribe(); } if (this.blockPageSubscription) { this.blockPageSubscription.unsubscribe(); } if (this.txConfirmedSubscription) { this.txConfirmedSubscription.unsubscribe(); } this.networkSubscription.unsubscribe(); this.tabHiddenSubscription.unsubscribe(); this.markBlockSubscription.unsubscribe(); this.blockDisplayModeSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe(); clearInterval(this.interval); } moveArrowToPosition(animate: boolean, newBlockFromLeft = false) { if (this.markHeight === undefined) { this.arrowVisible = false; return; } if (this.chainTip == null) { this.pendingMarkBlock = { animate, newBlockFromLeft }; } const blockindex = this.blocks.findIndex((b) => b.height === this.markHeight); if (blockindex > -1) { if (!animate) { this.arrowTransition = 'inherit'; } this.arrowVisible = true; if (newBlockFromLeft) { this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding - this.dividerBlockOffset; setTimeout(() => { this.arrowTransition = '2s'; this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding; this.cd.markForCheck(); }, 50); } else { this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding; if (!animate) { setTimeout(() => { this.arrowTransition = '2s'; this.cd.markForCheck(); }, 50); } } } else { this.arrowVisible = false; } } trackByBlocksFn(index: number, item: BlockchainBlock) { return item.height; } 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; if (block?.extras) { block.extras.minFee = this.getMinBlockFee(block); block.extras.maxFee = this.getMaxBlockFee(block); } } 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.blockOffset : 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) { if (block?.extras) { block.extras.minFee = this.getMinBlockFee(block); block.extras.maxFee = this.getMaxBlockFee(block); } this.blocks[blockIndex] = block; this.blockStyles[blockIndex] = this.getStyleForBlock(block, blockIndex); } this.cd.markForCheck(); } isSpecial(height: number): boolean { return this.specialBlocks[height]?.networks.includes(this.stateService.network || 'mainnet') ? true : false; } 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 (animateEnterFrom) { addLeft = animateEnterFrom || 0; } return { left: addLeft + this.blockOffset * index + 'px', background: `repeating-linear-gradient( var(--secondary), var(--secondary) ${greenBackgroundHeight}%, ${this.gradientColors[this.network][0]} ${Math.max(greenBackgroundHeight, 0)}%, ${this.gradientColors[this.network][1]} 100% )`, transition: animateEnterFrom ? 'background 2s, transform 1s' : null, }; } convertStyleForLoadingBlock(style) { return { ...style, background: "var(--secondary)", }; } getStyleForLoadingBlock(index: number, animateEnterFrom: number = 0) { const addLeft = animateEnterFrom || 0; return { left: addLeft + (this.blockOffset * index) + 'px', background: "var(--secondary)", }; } getStyleForPlaceholderBlock(index: number, animateEnterFrom: number = 0) { const addLeft = animateEnterFrom || 0; return { left: addLeft + (this.blockOffset * index) + 'px', }; } getStyleForEmptyBlock(block: BlockExtended, animateEnterFrom: number = 0) { const addLeft = animateEnterFrom || 0; return { left: addLeft + this.blockOffset * this.emptyBlocks.indexOf(block) + 'px', background: "var(--secondary)", }; } mountEmptyBlocks() { const emptyBlocks = []; for (let i = 0; i < this.dynamicBlocksAmount; i++) { emptyBlocks.push({ id: '', height: 0, version: 0, timestamp: 0, bits: 0, nonce: 0, difficulty: 0, merkle_root: '', tx_count: 0, size: 0, weight: 0, previousblockhash: '', matchRate: 0, }); } return emptyBlocks; } getMinBlockFee(block: BlockExtended): number { if (block?.extras?.feeRange) { // heuristic to check if feeRange is adjusted for effective rates if (block.extras.medianFee === block.extras.feeRange[3]) { return block.extras.feeRange[1]; } else { return block.extras.feeRange[0]; } } return 0; } getMaxBlockFee(block: BlockExtended): number { if (block?.extras?.feeRange) { return block.extras.feeRange[block.extras.feeRange.length - 1]; } return 0; } }