Merge pull request #1892 from mononaut/mined-block-visualization
This commit is contained in:
		
						commit
						95d645255d
					
				| @ -73,6 +73,14 @@ export namespace IBitcoinApi { | ||||
|     time: number;                    //  (numeric) Same as blocktime
 | ||||
|   } | ||||
| 
 | ||||
|   export interface VerboseBlock extends Block { | ||||
|     tx: VerboseTransaction[];        // The transactions in the format of the getrawtransaction RPC. Different from verbosity = 1 "tx" result
 | ||||
|   } | ||||
| 
 | ||||
|   export interface VerboseTransaction extends Transaction { | ||||
|     fee?: number;                   //  (numeric) The transaction fee in BTC, omitted if block undo data is not available
 | ||||
|   } | ||||
| 
 | ||||
|   export interface Vin { | ||||
|     txid?: string;                   //  (string) The transaction id
 | ||||
|     vout?: number;                   //  (string)
 | ||||
|  | ||||
| @ -2,11 +2,12 @@ import config from '../config'; | ||||
| import bitcoinApi from './bitcoin/bitcoin-api-factory'; | ||||
| import logger from '../logger'; | ||||
| import memPool from './mempool'; | ||||
| import { BlockExtended, PoolTag, TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces'; | ||||
| import { BlockExtended, BlockSummary, PoolTag, TransactionExtended, TransactionStripped, TransactionMinerInfo } from '../mempool.interfaces'; | ||||
| import { Common } from './common'; | ||||
| import diskCache from './disk-cache'; | ||||
| import transactionUtils from './transaction-utils'; | ||||
| import bitcoinClient from './bitcoin/bitcoin-client'; | ||||
| import { IBitcoinApi } from './bitcoin/bitcoin-api.interface'; | ||||
| import { IEsploraApi } from './bitcoin/esplora-api.interface'; | ||||
| import poolsRepository from '../repositories/PoolsRepository'; | ||||
| import blocksRepository from '../repositories/BlocksRepository'; | ||||
| @ -22,6 +23,7 @@ import poolsParser from './pools-parser'; | ||||
| 
 | ||||
| class Blocks { | ||||
|   private blocks: BlockExtended[] = []; | ||||
|   private blockSummaries: BlockSummary[] = []; | ||||
|   private currentBlockHeight = 0; | ||||
|   private currentDifficulty = 0; | ||||
|   private lastDifficultyAdjustmentTime = 0; | ||||
| @ -38,6 +40,14 @@ class Blocks { | ||||
|     this.blocks = blocks; | ||||
|   } | ||||
| 
 | ||||
|   public getBlockSummaries(): BlockSummary[] { | ||||
|     return this.blockSummaries; | ||||
|   } | ||||
| 
 | ||||
|   public setBlockSummaries(blockSummaries: BlockSummary[]) { | ||||
|     this.blockSummaries = blockSummaries; | ||||
|   } | ||||
| 
 | ||||
|   public setNewBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void) { | ||||
|     this.newBlockCallbacks.push(fn); | ||||
|   } | ||||
| @ -106,6 +116,27 @@ class Blocks { | ||||
|     return transactions; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Return a block summary (list of stripped transactions) | ||||
|    * @param block | ||||
|    * @returns BlockSummary | ||||
|    */ | ||||
|   private summarizeBlock(block: IBitcoinApi.VerboseBlock): BlockSummary { | ||||
|     const stripped = block.tx.map((tx) => { | ||||
|       return { | ||||
|         txid: tx.txid, | ||||
|         vsize: tx.vsize, | ||||
|         fee: tx.fee ? Math.round(tx.fee * 100000000) : 0, | ||||
|         value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0) * 100000000) | ||||
|       }; | ||||
|     }); | ||||
| 
 | ||||
|     return { | ||||
|       id: block.hash, | ||||
|       transactions: stripped | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Return a block with additional data (reward, coinbase, fees...) | ||||
|    * @param block | ||||
| @ -341,10 +372,12 @@ class Blocks { | ||||
|       } | ||||
| 
 | ||||
|       const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight); | ||||
|       const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash)); | ||||
|       const verboseBlock = await bitcoinClient.getBlock(blockHash, 2); | ||||
|       const block = BitcoinApi.convertBlock(verboseBlock); | ||||
|       const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash); | ||||
|       const transactions = await this.$getTransactionsExtended(blockHash, block.height, false); | ||||
|       const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions); | ||||
|       const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock); | ||||
| 
 | ||||
|       if (Common.indexingEnabled()) { | ||||
|         if (!fastForwarded) { | ||||
| @ -375,6 +408,10 @@ class Blocks { | ||||
|       if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) { | ||||
|         this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4); | ||||
|       } | ||||
|       this.blockSummaries.push(blockSummary); | ||||
|       if (this.blockSummaries.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) { | ||||
|         this.blockSummaries = this.blockSummaries.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4); | ||||
|       } | ||||
| 
 | ||||
|       if (this.newBlockCallbacks.length) { | ||||
|         this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions)); | ||||
| @ -440,6 +477,17 @@ class Blocks { | ||||
|     return blockExtended; | ||||
|   } | ||||
| 
 | ||||
|   public async $getStrippedBlockTransactions(hash: string): Promise<TransactionStripped[]> { | ||||
|     // Check the memory cache
 | ||||
|     const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); | ||||
|     if (cachedSummary) { | ||||
|       return cachedSummary.transactions; | ||||
|     } | ||||
|     const block = await bitcoinClient.getBlock(hash, 2); | ||||
|     const summary = this.summarizeBlock(block); | ||||
|     return summary.transactions; | ||||
|   } | ||||
| 
 | ||||
|   public async $getBlocks(fromHeight?: number, limit: number = 15): Promise<BlockExtended[]> { | ||||
|     try { | ||||
|       let currentHeight = fromHeight !== undefined ? fromHeight : this.getCurrentBlockHeight(); | ||||
|  | ||||
| @ -43,6 +43,7 @@ class DiskCache { | ||||
|       await fsPromises.writeFile(DiskCache.FILE_NAME, JSON.stringify({ | ||||
|         cacheSchemaVersion: this.cacheSchemaVersion, | ||||
|         blocks: blocks.getBlocks(), | ||||
|         blockSummaries: blocks.getBlockSummaries(), | ||||
|         mempool: {}, | ||||
|         mempoolArray: mempoolArray.splice(0, chunkSize), | ||||
|       }), {flag: 'w'}); | ||||
| @ -109,6 +110,7 @@ class DiskCache { | ||||
| 
 | ||||
|       memPool.setMempool(data.mempool); | ||||
|       blocks.setBlocks(data.blocks); | ||||
|       blocks.setBlockSummaries(data.blockSummaries || []); | ||||
|     } catch (e) { | ||||
|       logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
|  | ||||
| @ -314,7 +314,8 @@ class Server { | ||||
|     this.app | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'blocks', routes.getBlocks.bind(routes)) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', routes.getBlocks.bind(routes)) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', routes.getBlock); | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', routes.getBlock) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', routes.getStrippedBlockTransactions); | ||||
| 
 | ||||
|     if (config.MEMPOOL.BACKEND !== 'esplora') { | ||||
|       this.app | ||||
|  | ||||
| @ -106,6 +106,11 @@ export interface BlockExtended extends IEsploraApi.Block { | ||||
|   extras: BlockExtension; | ||||
| } | ||||
| 
 | ||||
| export interface BlockSummary { | ||||
|   id: string; | ||||
|   transactions: TransactionStripped[]; | ||||
| } | ||||
| 
 | ||||
| export interface TransactionMinerInfo { | ||||
|   vin: VinStrippedToScriptsig[]; | ||||
|   vout: VoutStrippedToScriptPubkey[]; | ||||
|  | ||||
| @ -726,6 +726,16 @@ class Routes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async getStrippedBlockTransactions(req: Request, res: Response) { | ||||
|     try { | ||||
|       const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); | ||||
|       res.json(transactions); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async getBlocks(req: Request, res: Response) { | ||||
|     try { | ||||
|       if (['mainnet', 'testnet', 'signet', 'regtest'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
 | ||||
|  | ||||
| @ -0,0 +1,12 @@ | ||||
| <div class="block-overview-graph"> | ||||
|   <canvas class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas> | ||||
|   <div class="loader-wrapper" [class.hidden]="!isLoading"> | ||||
|     <div class="spinner-border ml-3 loading" role="status"></div> | ||||
|   </div> | ||||
| 
 | ||||
|   <app-block-overview-tooltip | ||||
|     [tx]="selectedTx || hoverTx" | ||||
|     [cursorPosition]="tooltipPosition" | ||||
|     [clickable]="!!selectedTx" | ||||
|   ></app-block-overview-tooltip> | ||||
| </div> | ||||
| @ -1,4 +1,4 @@ | ||||
| .mempool-block-overview { | ||||
| .block-overview-graph { | ||||
|   position: relative; | ||||
|   width: 100%; | ||||
|   padding-bottom: 100%; | ||||
| @ -8,13 +8,20 @@ | ||||
|   align-items: center; | ||||
| } | ||||
| 
 | ||||
| .block-overview { | ||||
| 
 | ||||
| .block-overview-canvas { | ||||
|   position: absolute; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   top: 0; | ||||
|   bottom: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   overflow: hidden; | ||||
| 
 | ||||
|   &.clickable { | ||||
|     cursor: pointer; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .loader-wrapper { | ||||
| @ -27,6 +34,7 @@ | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   transition: opacity 500ms 500ms; | ||||
|   pointer-events: none; | ||||
| 
 | ||||
|   &.hidden { | ||||
|     opacity: 0; | ||||
| @ -0,0 +1,430 @@ | ||||
| import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core'; | ||||
| import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface'; | ||||
| import { WebsocketService } from 'src/app/services/websocket.service'; | ||||
| import { FastVertexArray } from './fast-vertex-array'; | ||||
| import BlockScene from './block-scene'; | ||||
| import TxSprite from './tx-sprite'; | ||||
| import TxView from './tx-view'; | ||||
| import { Position } from './sprite-types'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-overview-graph', | ||||
|   templateUrl: './block-overview-graph.component.html', | ||||
|   styleUrls: ['./block-overview-graph.component.scss'], | ||||
| }) | ||||
| export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { | ||||
|   @Input() isLoading: boolean; | ||||
|   @Input() resolution: number; | ||||
|   @Input() blockLimit: number; | ||||
|   @Input() orientation = 'left'; | ||||
|   @Input() flip = true; | ||||
|   @Output() txClickEvent = new EventEmitter<TransactionStripped>(); | ||||
| 
 | ||||
|   @ViewChild('blockCanvas') | ||||
|   canvas: ElementRef<HTMLCanvasElement>; | ||||
| 
 | ||||
|   gl: WebGLRenderingContext; | ||||
|   animationFrameRequest: number; | ||||
|   animationHeartBeat: number; | ||||
|   displayWidth: number; | ||||
|   displayHeight: number; | ||||
|   cssWidth: number; | ||||
|   cssHeight: number; | ||||
|   shaderProgram: WebGLProgram; | ||||
|   vertexArray: FastVertexArray; | ||||
|   running: boolean; | ||||
|   scene: BlockScene; | ||||
|   hoverTx: TxView | void; | ||||
|   selectedTx: TxView | void; | ||||
|   tooltipPosition: Position; | ||||
| 
 | ||||
|   constructor( | ||||
|     readonly ngZone: NgZone, | ||||
|     readonly elRef: ElementRef, | ||||
|   ) { | ||||
|     this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); | ||||
|   } | ||||
| 
 | ||||
|   ngAfterViewInit(): void { | ||||
|     this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false); | ||||
|     this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false); | ||||
|     this.gl = this.canvas.nativeElement.getContext('webgl'); | ||||
|     this.initCanvas(); | ||||
| 
 | ||||
|     this.resizeCanvas(); | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     if (this.animationFrameRequest) { | ||||
|       cancelAnimationFrame(this.animationFrameRequest); | ||||
|       clearTimeout(this.animationHeartBeat); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   clear(direction): void { | ||||
|     this.exit(direction); | ||||
|     this.hoverTx = null; | ||||
|     this.selectedTx = null; | ||||
|     this.start(); | ||||
|   } | ||||
| 
 | ||||
|   enter(transactions: TransactionStripped[], direction: string): void { | ||||
|     if (this.scene) { | ||||
|       this.scene.enter(transactions, direction); | ||||
|       this.start(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   exit(direction: string): void { | ||||
|     if (this.scene) { | ||||
|       this.scene.exit(direction); | ||||
|       this.start(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   replace(transactions: TransactionStripped[], direction: string, sort: boolean = true): void { | ||||
|     if (this.scene) { | ||||
|       this.scene.replace(transactions || [], direction, sort); | ||||
|       this.start(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { | ||||
|     if (this.scene) { | ||||
|       this.scene.update(add, remove, direction, resetLayout); | ||||
|       this.start(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   initCanvas(): void { | ||||
|     this.gl.clearColor(0.0, 0.0, 0.0, 0.0); | ||||
|     this.gl.clear(this.gl.COLOR_BUFFER_BIT); | ||||
| 
 | ||||
|     const shaderSet = [ | ||||
|       { | ||||
|         type: this.gl.VERTEX_SHADER, | ||||
|         src: vertShaderSrc | ||||
|       }, | ||||
|       { | ||||
|         type: this.gl.FRAGMENT_SHADER, | ||||
|         src: fragShaderSrc | ||||
|       } | ||||
|     ]; | ||||
| 
 | ||||
|     this.shaderProgram = this.buildShaderProgram(shaderSet); | ||||
| 
 | ||||
|     this.gl.useProgram(this.shaderProgram); | ||||
| 
 | ||||
|     // Set up alpha blending
 | ||||
|     this.gl.enable(this.gl.BLEND); | ||||
|     this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); | ||||
| 
 | ||||
|     const glBuffer = this.gl.createBuffer(); | ||||
|     this.gl.bindBuffer(this.gl.ARRAY_BUFFER, glBuffer); | ||||
| 
 | ||||
|     /* SET UP SHADER ATTRIBUTES */ | ||||
|     Object.keys(attribs).forEach((key, i) => { | ||||
|       attribs[key].pointer = this.gl.getAttribLocation(this.shaderProgram, key); | ||||
|       this.gl.enableVertexAttribArray(attribs[key].pointer); | ||||
|     }); | ||||
| 
 | ||||
|     this.start(); | ||||
|   } | ||||
| 
 | ||||
|   handleContextLost(event): void { | ||||
|     event.preventDefault(); | ||||
|     cancelAnimationFrame(this.animationFrameRequest); | ||||
|     this.animationFrameRequest = null; | ||||
|     this.running = false; | ||||
|   } | ||||
| 
 | ||||
|   handleContextRestored(event): void { | ||||
|     this.initCanvas(); | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('window:resize', ['$event']) | ||||
|   resizeCanvas(): void { | ||||
|     this.cssWidth = this.canvas.nativeElement.offsetParent.clientWidth; | ||||
|     this.cssHeight = this.canvas.nativeElement.offsetParent.clientHeight; | ||||
|     this.displayWidth = window.devicePixelRatio * this.cssWidth; | ||||
|     this.displayHeight = window.devicePixelRatio * this.cssHeight; | ||||
|     this.canvas.nativeElement.width = this.displayWidth; | ||||
|     this.canvas.nativeElement.height = this.displayHeight; | ||||
|     if (this.gl) { | ||||
|       this.gl.viewport(0, 0, this.displayWidth, this.displayHeight); | ||||
|     } | ||||
|     if (this.scene) { | ||||
|       this.scene.resize({ width: this.displayWidth, height: this.displayHeight }); | ||||
|       this.start(); | ||||
|     } else { | ||||
|       this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, | ||||
|         blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray }); | ||||
|       this.start(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   compileShader(src, type): WebGLShader { | ||||
|     const shader = this.gl.createShader(type); | ||||
| 
 | ||||
|     this.gl.shaderSource(shader, src); | ||||
|     this.gl.compileShader(shader); | ||||
| 
 | ||||
|     if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { | ||||
|       console.log(`Error compiling ${type === this.gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader:`); | ||||
|       console.log(this.gl.getShaderInfoLog(shader)); | ||||
|     } | ||||
|     return shader; | ||||
|   } | ||||
| 
 | ||||
|   buildShaderProgram(shaderInfo): WebGLProgram { | ||||
|     const program = this.gl.createProgram(); | ||||
| 
 | ||||
|     shaderInfo.forEach((desc) => { | ||||
|       const shader = this.compileShader(desc.src, desc.type); | ||||
|       if (shader) { | ||||
|         this.gl.attachShader(program, shader); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     this.gl.linkProgram(program); | ||||
| 
 | ||||
|     if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) { | ||||
|       console.log('Error linking shader program:'); | ||||
|       console.log(this.gl.getProgramInfoLog(program)); | ||||
|     } | ||||
| 
 | ||||
|     return program; | ||||
|   } | ||||
| 
 | ||||
|   start(): void { | ||||
|     this.running = true; | ||||
|     this.ngZone.runOutsideAngular(() => this.doRun()); | ||||
|   } | ||||
| 
 | ||||
|   doRun(): void { | ||||
|     if (this.animationFrameRequest) { | ||||
|       cancelAnimationFrame(this.animationFrameRequest); | ||||
|     } | ||||
|     this.animationFrameRequest = requestAnimationFrame(() => this.run()); | ||||
|   } | ||||
| 
 | ||||
|   run(now?: DOMHighResTimeStamp): void { | ||||
|     if (!now) { | ||||
|       now = performance.now(); | ||||
|     } | ||||
|     // skip re-render if there's no change to the scene
 | ||||
|     if (this.scene) { | ||||
|       /* SET UP SHADER UNIFORMS */ | ||||
|       // screen dimensions
 | ||||
|       this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight); | ||||
|       // frame timestamp
 | ||||
|       this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, 'now'), now); | ||||
| 
 | ||||
|       if (this.vertexArray.dirty) { | ||||
|         /* SET UP SHADER ATTRIBUTES */ | ||||
|         Object.keys(attribs).forEach((key, i) => { | ||||
|           this.gl.vertexAttribPointer(attribs[key].pointer, | ||||
|           attribs[key].count,  // number of primitives in this attribute
 | ||||
|           this.gl[attribs[key].type],  // type of primitive in this attribute (e.g. gl.FLOAT)
 | ||||
|           false, // never normalised
 | ||||
|           stride,   // distance between values of the same attribute
 | ||||
|           attribs[key].offset);  // offset of the first value
 | ||||
|         }); | ||||
| 
 | ||||
|         const pointArray = this.vertexArray.getVertexData(); | ||||
| 
 | ||||
|         if (pointArray.length) { | ||||
|           this.gl.bufferData(this.gl.ARRAY_BUFFER, pointArray, this.gl.DYNAMIC_DRAW); | ||||
|           this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize); | ||||
|         } | ||||
|         this.vertexArray.dirty = false; | ||||
|       } else { | ||||
|         const pointArray = this.vertexArray.getVertexData(); | ||||
|         if (pointArray.length) { | ||||
|           this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     /* LOOP */ | ||||
|     if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) { | ||||
|       this.doRun(); | ||||
|     } else { | ||||
|       if (this.animationHeartBeat) { | ||||
|         clearTimeout(this.animationHeartBeat); | ||||
|       } | ||||
|       this.animationHeartBeat = window.setTimeout(() => { | ||||
|         this.start(); | ||||
|       }, 1000); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('document:click', ['$event']) | ||||
|   clickAway(event) { | ||||
|     if (!this.elRef.nativeElement.contains(event.target)) { | ||||
|       const currentPreview = this.selectedTx || this.hoverTx; | ||||
|       if (currentPreview && this.scene) { | ||||
|         this.scene.setHover(currentPreview, false); | ||||
|         this.start(); | ||||
|       } | ||||
|       this.hoverTx = null; | ||||
|       this.selectedTx = null; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('pointerup', ['$event']) | ||||
|   onClick(event) { | ||||
|     if (event.target === this.canvas.nativeElement && event.pointerType === 'touch') { | ||||
|       this.setPreviewTx(event.offsetX, event.offsetY, true); | ||||
|     } else if (event.target === this.canvas.nativeElement) { | ||||
|       this.onTxClick(event.offsetX, event.offsetY); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('pointermove', ['$event']) | ||||
|   onPointerMove(event) { | ||||
|     if (event.target === this.canvas.nativeElement) { | ||||
|       this.setPreviewTx(event.offsetX, event.offsetY, false); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('pointerleave', ['$event']) | ||||
|   onPointerLeave(event) { | ||||
|     if (event.pointerType !== 'touch') { | ||||
|       this.setPreviewTx(-1, -1, true); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setPreviewTx(cssX: number, cssY: number, clicked: boolean = false) { | ||||
|     const x = cssX * window.devicePixelRatio; | ||||
|     const y = cssY * window.devicePixelRatio; | ||||
|     if (this.scene && (!this.selectedTx || clicked)) { | ||||
|       this.tooltipPosition = { | ||||
|         x: cssX, | ||||
|         y: cssY | ||||
|       }; | ||||
|       const selected = this.scene.getTxAt({ x, y }); | ||||
|       const currentPreview = this.selectedTx || this.hoverTx; | ||||
| 
 | ||||
|       if (selected !== currentPreview) { | ||||
|         if (currentPreview && this.scene) { | ||||
|           this.scene.setHover(currentPreview, false); | ||||
|           this.start(); | ||||
|         } | ||||
|         if (selected) { | ||||
|           if (selected && this.scene) { | ||||
|             this.scene.setHover(selected, true); | ||||
|             this.start(); | ||||
|           } | ||||
|           if (clicked) { | ||||
|             this.selectedTx = selected; | ||||
|           } else { | ||||
|             this.hoverTx = selected; | ||||
|           } | ||||
|         } else { | ||||
|           if (clicked) { | ||||
|             this.selectedTx = null; | ||||
|           } | ||||
|           this.hoverTx = null; | ||||
|         } | ||||
|       } else if (clicked) { | ||||
|         if (selected === this.selectedTx) { | ||||
|           this.hoverTx = this.selectedTx; | ||||
|           this.selectedTx = null; | ||||
|         } else { | ||||
|           this.selectedTx = selected; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onTxClick(cssX: number, cssY: number) { | ||||
|     const x = cssX * window.devicePixelRatio; | ||||
|     const y = cssY * window.devicePixelRatio; | ||||
|     const selected = this.scene.getTxAt({ x, y }); | ||||
|     if (selected && selected.txid) { | ||||
|       this.txClickEvent.emit(selected); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // WebGL shader attributes
 | ||||
| const attribs = { | ||||
|   offset: { type: 'FLOAT', count: 2, pointer: null, offset: 0 }, | ||||
|   posX: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   posY: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   posR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   colR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   colG: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   colB: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   colA: { type: 'FLOAT', count: 4, pointer: null, offset: 0 } | ||||
| }; | ||||
| // Calculate the number of bytes per vertex based on specified attributes
 | ||||
| const stride = Object.values(attribs).reduce((total, attrib) => { | ||||
|   return total + (attrib.count * 4); | ||||
| }, 0); | ||||
| // Calculate vertex attribute offsets
 | ||||
| for (let i = 0, offset = 0; i < Object.keys(attribs).length; i++) { | ||||
|   const attrib = Object.values(attribs)[i]; | ||||
|   attrib.offset = offset; | ||||
|   offset += (attrib.count * 4); | ||||
| } | ||||
| 
 | ||||
| const vertShaderSrc = ` | ||||
| varying lowp vec4 vColor; | ||||
| 
 | ||||
| // each attribute contains [x: startValue, y: endValue, z: startTime, w: rate]
 | ||||
| // shader interpolates between start and end values at the given rate, from the given time
 | ||||
| 
 | ||||
| attribute vec2 offset; | ||||
| attribute vec4 posX; | ||||
| attribute vec4 posY; | ||||
| attribute vec4 posR; | ||||
| attribute vec4 colR; | ||||
| attribute vec4 colG; | ||||
| attribute vec4 colB; | ||||
| attribute vec4 colA; | ||||
| 
 | ||||
| uniform vec2 screenSize; | ||||
| uniform float now; | ||||
| 
 | ||||
| float smootherstep(float x) { | ||||
|   x = clamp(x, 0.0, 1.0); | ||||
|   float ix = 1.0 - x; | ||||
|   x = x * x; | ||||
|   return x / (x + ix * ix); | ||||
| } | ||||
| 
 | ||||
| float interpolateAttribute(vec4 attr) { | ||||
|   float d = (now - attr.z) * attr.w; | ||||
|   float delta = smootherstep(d); | ||||
|   return mix(attr.x, attr.y, delta); | ||||
| } | ||||
| 
 | ||||
| void main() { | ||||
|   vec4 screenTransform = vec4(2.0 / screenSize.x, 2.0 / screenSize.y, -1.0, -1.0); | ||||
|   // vec4 screenTransform = vec4(1.0 / screenSize.x, 1.0 / screenSize.y, -0.5, -0.5);
 | ||||
| 
 | ||||
|   float radius = interpolateAttribute(posR); | ||||
|   vec2 position = vec2(interpolateAttribute(posX), interpolateAttribute(posY)) + (radius * offset); | ||||
| 
 | ||||
|   gl_Position = vec4(position * screenTransform.xy + screenTransform.zw, 1.0, 1.0); | ||||
| 
 | ||||
|   float red = interpolateAttribute(colR); | ||||
|   float green = interpolateAttribute(colG); | ||||
|   float blue = interpolateAttribute(colB); | ||||
|   float alpha = interpolateAttribute(colA); | ||||
| 
 | ||||
|   vColor = vec4(red, green, blue, alpha); | ||||
| } | ||||
| `;
 | ||||
| 
 | ||||
| const fragShaderSrc = ` | ||||
| varying lowp vec4 vColor; | ||||
| 
 | ||||
| void main() { | ||||
|   gl_FragColor = vColor; | ||||
|   // premultiply alpha
 | ||||
|   gl_FragColor.rgb *= gl_FragColor.a; | ||||
| } | ||||
| `;
 | ||||
							
								
								
									
										784
									
								
								frontend/src/app/components/block-overview-graph/block-scene.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										784
									
								
								frontend/src/app/components/block-overview-graph/block-scene.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,784 @@ | ||||
| import { FastVertexArray } from './fast-vertex-array'; | ||||
| import TxView from './tx-view'; | ||||
| import { TransactionStripped } from 'src/app/interfaces/websocket.interface'; | ||||
| import { Position, Square, ViewUpdateParams } from './sprite-types'; | ||||
| 
 | ||||
| export default class BlockScene { | ||||
|   scene: { count: number, offset: { x: number, y: number}}; | ||||
|   vertexArray: FastVertexArray; | ||||
|   txs: { [key: string]: TxView }; | ||||
|   orientation: string; | ||||
|   flip: boolean; | ||||
|   width: number; | ||||
|   height: number; | ||||
|   gridWidth: number; | ||||
|   gridHeight: number; | ||||
|   gridSize: number; | ||||
|   vbytesPerUnit: number; | ||||
|   unitPadding: number; | ||||
|   unitWidth: number; | ||||
|   initialised: boolean; | ||||
|   layout: BlockLayout; | ||||
|   animateUntil = 0; | ||||
|   dirty: boolean; | ||||
| 
 | ||||
|   constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: | ||||
|       { width: number, height: number, resolution: number, blockLimit: number, | ||||
|         orientation: string, flip: boolean, vertexArray: FastVertexArray } | ||||
|   ) { | ||||
|     this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }); | ||||
|   } | ||||
| 
 | ||||
|   destroy(): void { | ||||
|     Object.values(this.txs).forEach(tx => tx.destroy()); | ||||
|   } | ||||
| 
 | ||||
|   resize({ width = this.width, height = this.height }: { width?: number, height?: number}): void { | ||||
|     this.width = width; | ||||
|     this.height = height; | ||||
|     this.gridSize = this.width / this.gridWidth; | ||||
|     this.unitPadding =  width / 500; | ||||
|     this.unitWidth = this.gridSize - (this.unitPadding * 2); | ||||
| 
 | ||||
|     this.dirty = true; | ||||
|     if (this.initialised && this.scene) { | ||||
|       this.updateAll(performance.now(), 50); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Animate new block entering scene
 | ||||
|   enter(txs: TransactionStripped[], direction) { | ||||
|     this.replace(txs, direction); | ||||
|   } | ||||
| 
 | ||||
|   // Animate block leaving scene
 | ||||
|   exit(direction: string): void { | ||||
|     const startTime = performance.now(); | ||||
|     const removed = this.removeBatch(Object.keys(this.txs), startTime, direction); | ||||
| 
 | ||||
|     // clean up sprites
 | ||||
|     setTimeout(() => { | ||||
|       removed.forEach(tx => { | ||||
|         tx.destroy(); | ||||
|       }); | ||||
|     }, 2000); | ||||
|   } | ||||
| 
 | ||||
|   // Reset layout and replace with new set of transactions
 | ||||
|   replace(txs: TransactionStripped[], direction: string = 'left', sort: boolean = true): void { | ||||
|     const startTime = performance.now(); | ||||
|     const nextIds = {}; | ||||
|     const remove = []; | ||||
|     txs.forEach(tx => { | ||||
|       nextIds[tx.txid] = true; | ||||
|     }); | ||||
|     Object.keys(this.txs).forEach(txid => { | ||||
|       if (!nextIds[txid]) { | ||||
|         remove.push(txid); | ||||
|       } | ||||
|     }); | ||||
|     txs.forEach(tx => { | ||||
|       if (!this.txs[tx.txid]) { | ||||
|         this.txs[tx.txid] = new TxView(tx, this.vertexArray); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     const removed = this.removeBatch(remove, startTime, direction); | ||||
| 
 | ||||
|     // clean up sprites
 | ||||
|     setTimeout(() => { | ||||
|       removed.forEach(tx => { | ||||
|         tx.destroy(); | ||||
|       }); | ||||
|     }, 1000); | ||||
| 
 | ||||
|     this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); | ||||
| 
 | ||||
|     if (sort) { | ||||
|       Object.values(this.txs).sort(feeRateDescending).forEach(tx => { | ||||
|         this.place(tx); | ||||
|       }); | ||||
|     } else { | ||||
|       txs.forEach(tx => { | ||||
|         this.place(this.txs[tx.txid]); | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     this.updateAll(startTime, 200, direction); | ||||
|   } | ||||
| 
 | ||||
|   update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { | ||||
|     const startTime = performance.now(); | ||||
|     const removed = this.removeBatch(remove, startTime, direction); | ||||
| 
 | ||||
|     // clean up sprites
 | ||||
|     setTimeout(() => { | ||||
|       removed.forEach(tx => { | ||||
|         tx.destroy(); | ||||
|       }); | ||||
|     }, 1000); | ||||
| 
 | ||||
|     if (resetLayout) { | ||||
|       add.forEach(tx => { | ||||
|         if (!this.txs[tx.txid]) { | ||||
|           this.txs[tx.txid] = new TxView(tx, this.vertexArray); | ||||
|         } | ||||
|       }); | ||||
|       this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); | ||||
|       Object.values(this.txs).sort(feeRateDescending).forEach(tx => { | ||||
|         this.place(tx); | ||||
|       }); | ||||
|     } else { | ||||
|       // try to insert new txs directly
 | ||||
|       const remaining = []; | ||||
|       add.map(tx => new TxView(tx, this.vertexArray)).sort(feeRateDescending).forEach(tx => { | ||||
|         if (!this.tryInsertByFee(tx)) { | ||||
|           remaining.push(tx); | ||||
|         } | ||||
|       }); | ||||
|       this.placeBatch(remaining); | ||||
|       this.layout.applyGravity(); | ||||
|     } | ||||
| 
 | ||||
|     this.updateAll(startTime, 100, direction); | ||||
|   } | ||||
| 
 | ||||
|   // return the tx at this screen position, if any
 | ||||
|   getTxAt(position: Position): TxView | void { | ||||
|     if (this.layout) { | ||||
|       const gridPosition = this.screenToGrid(position); | ||||
|       return this.layout.getTx(gridPosition); | ||||
|     } else { | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setHover(tx: TxView, value: boolean): void { | ||||
|     this.animateUntil = Math.max(this.animateUntil, tx.setHover(value)); | ||||
|   } | ||||
| 
 | ||||
|   private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: | ||||
|       { width: number, height: number, resolution: number, blockLimit: number, | ||||
|         orientation: string, flip: boolean, vertexArray: FastVertexArray } | ||||
|   ): void { | ||||
|     this.orientation = orientation; | ||||
|     this.flip = flip; | ||||
|     this.vertexArray = vertexArray; | ||||
| 
 | ||||
|     this.scene = { | ||||
|       count: 0, | ||||
|       offset: { | ||||
|         x: 0, | ||||
|         y: 0 | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     // Set the scale of the visualization (with a 5% margin)
 | ||||
|     this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.02, 2); | ||||
|     this.gridWidth = resolution; | ||||
|     this.gridHeight = resolution; | ||||
|     this.resize({ width, height }); | ||||
|     this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); | ||||
| 
 | ||||
|     this.txs = {}; | ||||
| 
 | ||||
|     this.initialised = true; | ||||
|     this.dirty = true; | ||||
|   } | ||||
| 
 | ||||
|   private applyTxUpdate(tx: TxView, update: ViewUpdateParams): void { | ||||
|     this.animateUntil = Math.max(this.animateUntil, tx.update(update)); | ||||
|   } | ||||
| 
 | ||||
|   private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left'): void { | ||||
|     if (tx.dirty || this.dirty) { | ||||
|       this.saveGridToScreenPosition(tx); | ||||
|       this.setTxOnScreen(tx, startTime, delay, direction); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left'): void { | ||||
|     if (!tx.initialised) { | ||||
|       const txColor = tx.getColor(); | ||||
|       this.applyTxUpdate(tx, { | ||||
|         display: { | ||||
|           position: { | ||||
|             x: tx.screenPosition.x + (direction === 'right' ? -this.width : (direction === 'left' ? this.width : 0)) * 1.4, | ||||
|             y: tx.screenPosition.y + (direction === 'up' ? -this.height : (direction === 'down' ? this.height : 0)) * 1.4, | ||||
|             s: tx.screenPosition.s | ||||
|           }, | ||||
|           color: txColor, | ||||
|         }, | ||||
|         start: startTime, | ||||
|         delay: 0, | ||||
|       }); | ||||
|       this.applyTxUpdate(tx, { | ||||
|         display: { | ||||
|           position: tx.screenPosition, | ||||
|           color: txColor | ||||
|         }, | ||||
|         duration: 1000, | ||||
|         start: startTime, | ||||
|         delay, | ||||
|       }); | ||||
|     } else { | ||||
|       this.applyTxUpdate(tx, { | ||||
|         display: { | ||||
|           position: tx.screenPosition | ||||
|         }, | ||||
|         duration: 1000, | ||||
|         minDuration: 500, | ||||
|         start: startTime, | ||||
|         delay, | ||||
|         adjust: true | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private updateAll(startTime: number, delay: number = 50, direction: string = 'left'): void { | ||||
|     this.scene.count = 0; | ||||
|     const ids = this.getTxList(); | ||||
|     startTime = startTime || performance.now(); | ||||
|     for (const id of ids) { | ||||
|       this.updateTx(this.txs[id], startTime, delay, direction); | ||||
|     } | ||||
|     this.dirty = false; | ||||
|   } | ||||
| 
 | ||||
|   private remove(id: string, startTime: number, direction: string = 'left'): TxView | void { | ||||
|     const tx = this.txs[id]; | ||||
|     if (tx) { | ||||
|       this.layout.remove(tx); | ||||
|       this.applyTxUpdate(tx, { | ||||
|         display: { | ||||
|           position: { | ||||
|             x: tx.screenPosition.x + (direction === 'right' ? this.width : (direction === 'left' ? -this.width : 0)) * 1.4, | ||||
|             y: tx.screenPosition.y + (direction === 'up' ? this.height : (direction === 'down' ? -this.height : 0)) * 1.4, | ||||
|           } | ||||
|         }, | ||||
|         duration: 1000, | ||||
|         start: startTime, | ||||
|         delay: 50 | ||||
|       }); | ||||
|     } | ||||
|     delete this.txs[id]; | ||||
|     return tx; | ||||
|   } | ||||
| 
 | ||||
|   private getTxList(): string[] { | ||||
|     return Object.keys(this.txs); | ||||
|   } | ||||
| 
 | ||||
|   private saveGridToScreenPosition(tx: TxView): void { | ||||
|     tx.screenPosition = this.gridToScreen(tx.gridPosition); | ||||
|   } | ||||
| 
 | ||||
|   // convert grid coordinates to screen coordinates
 | ||||
|   private gridToScreen(position: Square | void): Square { | ||||
|     if (position) { | ||||
|       const slotSize = (position.s * this.gridSize); | ||||
|       const squareSize = slotSize - (this.unitPadding * 2); | ||||
| 
 | ||||
|       // The grid is laid out notionally left-to-right, bottom-to-top,
 | ||||
|       // so we rotate and/or flip the y axis to match the target configuration.
 | ||||
|       //
 | ||||
|       // e.g. for flip = true, orientation = 'left':
 | ||||
|       //
 | ||||
|       //    grid                             screen
 | ||||
|       //  ________        ________          ________
 | ||||
|       // |        |      |        |        |       a|
 | ||||
|       // |        | flip |        | rotate |     c  |
 | ||||
|       // |  c     |  --> |     c  |  -->   |        |
 | ||||
|       // |a______b|      |b______a|        |_______b|
 | ||||
| 
 | ||||
|       let x = (this.gridSize * position.x) + (slotSize / 2); | ||||
|       let y = (this.gridSize * position.y) + (slotSize / 2); | ||||
|       let t; | ||||
|       if (this.flip) { | ||||
|         x = this.width - x; | ||||
|       } | ||||
|       switch (this.orientation) { | ||||
|         case 'left': | ||||
|           t = x; | ||||
|           x = this.width - y; | ||||
|           y = t; | ||||
|           break; | ||||
|         case 'right': | ||||
|           t = x; | ||||
|           x = y; | ||||
|           y = t; | ||||
|           break; | ||||
|         case 'bottom': | ||||
|           y = this.height - y; | ||||
|           break; | ||||
|       } | ||||
|       return { | ||||
|         x: x + this.unitPadding - (slotSize / 2), | ||||
|         y: y + this.unitPadding - (slotSize / 2), | ||||
|         s: squareSize | ||||
|       }; | ||||
|     } else { | ||||
|       return { x: 0, y: 0, s: 0 }; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private screenToGrid(position: Position): Position { | ||||
|     let x = position.x; | ||||
|     let y = this.height - position.y; | ||||
|     let t; | ||||
| 
 | ||||
|     switch (this.orientation) { | ||||
|       case 'left': | ||||
|         t = x; | ||||
|         x = y; | ||||
|         y = this.width - t; | ||||
|         break; | ||||
|       case 'right': | ||||
|         t = x; | ||||
|         x = y; | ||||
|         y = t; | ||||
|         break; | ||||
|       case 'bottom': | ||||
|         y = this.height - y; | ||||
|         break; | ||||
|     } | ||||
|     if (this.flip) { | ||||
|       x = this.width - x; | ||||
|     } | ||||
|     return { | ||||
|       x: Math.floor(x / this.gridSize), | ||||
|       y: Math.floor(y / this.gridSize) | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   // calculates and returns the size of the tx in multiples of the grid size
 | ||||
|   private txSize(tx: TxView): number { | ||||
|     const scale = Math.max(1, Math.round(Math.sqrt(tx.vsize / this.vbytesPerUnit))); | ||||
|     return Math.min(this.gridWidth, Math.max(1, scale)); // bound between 1 and the max displayable size (just in case!)
 | ||||
|   } | ||||
| 
 | ||||
|   private place(tx: TxView): void { | ||||
|     const size = this.txSize(tx); | ||||
|     this.layout.insert(tx, size); | ||||
|   } | ||||
| 
 | ||||
|   private tryInsertByFee(tx: TxView): boolean { | ||||
|     const size = this.txSize(tx); | ||||
|     const position = this.layout.tryInsertByFee(tx, size); | ||||
|     if (position) { | ||||
|       this.txs[tx.txid] = tx; | ||||
|       return true; | ||||
|     } else { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Add a list of transactions to the layout,
 | ||||
|   // keeping everything approximately sorted by feerate.
 | ||||
|   private placeBatch(txs: TxView[]): void { | ||||
|     if (txs.length) { | ||||
|       // grab the new tx with the highest fee rate
 | ||||
|       txs = txs.sort(feeRateDescending); | ||||
|       const maxSize = 2 * txs.reduce((max, tx) => { | ||||
|         return Math.max(this.txSize(tx), max); | ||||
|       }, 1); | ||||
| 
 | ||||
|       // find a reasonable place for it in the layout
 | ||||
|       const root = this.layout.getReplacementRoot(txs[0].feerate, maxSize); | ||||
| 
 | ||||
|       // extract a sub tree of transactions from the layout, rooted at that point
 | ||||
|       const popped = this.layout.popTree(root.x, root.y, maxSize); | ||||
|       // combine those with the new transactions and sort
 | ||||
|       txs = txs.concat(popped); | ||||
|       txs = txs.sort(feeRateDescending); | ||||
| 
 | ||||
|       // insert everything back into the layout
 | ||||
|       txs.forEach(tx => { | ||||
|         this.txs[tx.txid] = tx; | ||||
|         this.place(tx); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private removeBatch(ids: string[], startTime: number, direction: string = 'left'): TxView[] { | ||||
|     if (!startTime) { | ||||
|       startTime = performance.now(); | ||||
|     } | ||||
|     return ids.map(id => { | ||||
|       return this.remove(id, startTime, direction); | ||||
|     }).filter(tx => tx != null) as TxView[]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| class Slot { | ||||
|   l: number; | ||||
|   r: number; | ||||
|   w: number; | ||||
| 
 | ||||
|   constructor(l: number, r: number) { | ||||
|     this.l = l; | ||||
|     this.r = r; | ||||
|     this.w = r - l; | ||||
|   } | ||||
| 
 | ||||
|   intersects(slot: Slot): boolean { | ||||
|     return !((slot.r <= this.l) || (slot.l >= this.r)); | ||||
|   } | ||||
| 
 | ||||
|   subtract(slot: Slot): Slot[] | void { | ||||
|     if (this.intersects(slot)) { | ||||
|       // from middle
 | ||||
|       if (slot.l > this.l && slot.r < this.r) { | ||||
|         return [ | ||||
|           new Slot(this.l, slot.l), | ||||
|           new Slot(slot.r, this.r) | ||||
|         ]; | ||||
|       } // totally covered
 | ||||
|       else if (slot.l <= this.l && slot.r >= this.r) { | ||||
|         return []; | ||||
|       } // from left side
 | ||||
|       else if (slot.l <= this.l) { | ||||
|         if (slot.r === this.r) { | ||||
|           return []; | ||||
|         } else { | ||||
|           return [new Slot(slot.r, this.r)]; | ||||
|         } | ||||
|       } // from right side
 | ||||
|       else if (slot.r >= this.r) { | ||||
|         if (slot.l === this.l) { | ||||
|           return []; | ||||
|         } else { | ||||
|           return [new Slot(this.l, slot.l)]; | ||||
|         } | ||||
|       } | ||||
|     } else { | ||||
|       return [this]; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class TxSlot extends Slot { | ||||
|   tx: TxView; | ||||
| 
 | ||||
|   constructor(l: number, r: number, tx: TxView) { | ||||
|     super(l, r); | ||||
|     this.tx = tx; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Row { | ||||
|   y: number; | ||||
|   w: number; | ||||
|   filled: TxSlot[]; | ||||
|   slots: Slot[]; | ||||
| 
 | ||||
| 
 | ||||
|   constructor(y: number, width: number) { | ||||
|     this.y = y; | ||||
|     this.w = width; | ||||
|     this.filled = []; | ||||
|     this.slots = [new Slot(0, this.w)]; | ||||
|   } | ||||
| 
 | ||||
|   // insert a transaction w/ given width into row starting at position x
 | ||||
|   insert(x: number, w: number, tx: TxView): void { | ||||
|     const newSlot = new TxSlot(x, x + w, tx); | ||||
|     // insert into filled list
 | ||||
|     let index = this.filled.findIndex((slot) => (slot.l >= newSlot.r)); | ||||
|     if (index < 0) { | ||||
|       index = this.filled.length; | ||||
|     } | ||||
|     this.filled.splice(index || 0, 0, newSlot); | ||||
|     // subtract from overlapping slots
 | ||||
|     for (let i = 0; i < this.slots.length; i++) { | ||||
|       if (newSlot.intersects(this.slots[i])) { | ||||
|         const diff = this.slots[i].subtract(newSlot); | ||||
|         if (diff) { | ||||
|           this.slots.splice(i, 1, ...diff); | ||||
|           i += diff.length - 1; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   remove(x: number, w: number): void { | ||||
|     const txIndex = this.filled.findIndex((slot) => (slot.l === x) ); | ||||
|     this.filled.splice(txIndex, 1); | ||||
| 
 | ||||
|     const newSlot = new Slot(x, x + w); | ||||
|     let slotIndex = this.slots.findIndex((slot) => (slot.l >= newSlot.r) ); | ||||
|     if (slotIndex < 0) { | ||||
|       slotIndex = this.slots.length; | ||||
|     } | ||||
|     this.slots.splice(slotIndex || 0, 0, newSlot); | ||||
|     this.normalize(); | ||||
|   } | ||||
| 
 | ||||
|   // merge any contiguous empty slots
 | ||||
|   private normalize(): void { | ||||
|     for (let i = 0; i < this.slots.length - 1; i++) { | ||||
|       if (this.slots[i].r === this.slots[i + 1].l) { | ||||
|         this.slots[i].r = this.slots[i + 1].r; | ||||
|         this.slots[i].w += this.slots[i + 1].w; | ||||
|         this.slots.splice(i + 1, 1); | ||||
|         i--; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   txAt(x: number): TxView | void { | ||||
|     let i = 0; | ||||
|     while (i < this.filled.length && this.filled[i].l <= x) { | ||||
|       if (this.filled[i].l <= x && this.filled[i].r > x) { | ||||
|         return this.filled[i].tx; | ||||
|       } | ||||
|       i++; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   getSlotsBetween(left: number, right: number): TxSlot[] { | ||||
|     const range = new Slot(left, right); | ||||
|     return this.filled.filter(slot => { | ||||
|       return slot.intersects(range); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   slotAt(x: number): Slot | void { | ||||
|     let i = 0; | ||||
|     while (i < this.slots.length && this.slots[i].l <= x) { | ||||
|       if (this.slots[i].l <= x && this.slots[i].r > x) { | ||||
|         return this.slots[i]; | ||||
|       } | ||||
|       i++; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   getAvgFeerate(): number { | ||||
|     let count = 0; | ||||
|     let total = 0; | ||||
|     this.filled.forEach(slot => { | ||||
|       if (slot.tx) { | ||||
|         count += slot.w; | ||||
|         total += (slot.tx.feerate * slot.w); | ||||
|       } | ||||
|     }); | ||||
|     return total / count; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class BlockLayout { | ||||
|   width: number; | ||||
|   height: number; | ||||
|   rows: Row[]; | ||||
|   txPositions: { [key: string]: Square }; | ||||
|   txs: { [key: string]: TxView }; | ||||
| 
 | ||||
|   constructor({ width, height }: { width: number, height: number }) { | ||||
|     this.width = width; | ||||
|     this.height = height; | ||||
|     this.rows = [new Row(0, this.width)]; | ||||
|     this.txPositions = {}; | ||||
|     this.txs = {}; | ||||
|   } | ||||
| 
 | ||||
|   getRow(position: Square): Row { | ||||
|     return this.rows[position.y]; | ||||
|   } | ||||
| 
 | ||||
|   getTx(position: Square): TxView | void { | ||||
|     if (this.getRow(position)) { | ||||
|       return this.getRow(position).txAt(position.x); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   addRow(): void { | ||||
|     this.rows.push(new Row(this.rows.length, this.width)); | ||||
|   } | ||||
| 
 | ||||
|   remove(tx: TxView) { | ||||
|     const position = this.txPositions[tx.txid]; | ||||
|     if (position) { | ||||
|       for (let y = position.y; y < position.y + position.s && y < this.rows.length; y++) { | ||||
|         this.rows[y].remove(position.x, position.s); | ||||
|       } | ||||
|     } | ||||
|     delete this.txPositions[tx.txid]; | ||||
|     delete this.txs[tx.txid]; | ||||
|   } | ||||
| 
 | ||||
|   insert(tx: TxView, width: number): Square { | ||||
|     const fit = this.fit(tx, width); | ||||
| 
 | ||||
|     // insert the tx into rows at that position
 | ||||
|     for (let y = fit.y; y < fit.y + width; y++) { | ||||
|       if (y >= this.rows.length) { | ||||
|         this.addRow(); | ||||
|       } | ||||
|       this.rows[y].insert(fit.x, width, tx); | ||||
|     } | ||||
|     const position = { x: fit.x, y: fit.y, s: width }; | ||||
|     this.txPositions[tx.txid] = position; | ||||
|     this.txs[tx.txid] = tx; | ||||
|     tx.applyGridPosition(position); | ||||
|     return position; | ||||
|   } | ||||
| 
 | ||||
|   // Find the first slot large enough to hold a transaction of this size
 | ||||
|   fit(tx: TxView, width: number): Square { | ||||
|     let fit; | ||||
|     for (let y = 0; y < this.rows.length && !fit; y++) { | ||||
|       fit = this.findFit(0, this.width, y, y, width); | ||||
|     } | ||||
|     // fall back to placing tx in a new row at the top of the layout
 | ||||
|     if (!fit) { | ||||
|       fit = { x: 0, y: this.rows.length }; | ||||
|     } | ||||
|     return fit; | ||||
|   } | ||||
| 
 | ||||
|   // recursively check rows to see if there's space for a tx (depth-first)
 | ||||
|   // left/right: initial column boundaries to check
 | ||||
|   // row: current row to check
 | ||||
|   // start: starting row
 | ||||
|   // size: size of space needed
 | ||||
|   findFit(left: number, right: number, row: number, start: number, size: number): Square { | ||||
|     if ((row - start) >= size || row >= this.rows.length) { | ||||
|       return { x: left, y: start }; | ||||
|     } | ||||
|     for (const slot of this.rows[row].slots) { | ||||
|       const l = Math.max(left, slot.l); | ||||
|       const r = Math.min(right, slot.r); | ||||
|       if (r - l >= size) { | ||||
|         const fit = this.findFit(l, r, row + 1, start, size); | ||||
|         if (fit) { | ||||
|           return fit; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // insert only if the tx fits into a fee-appropriate position
 | ||||
|   tryInsertByFee(tx: TxView, size: number): Square | void { | ||||
|     const fit = this.fit(tx, size); | ||||
| 
 | ||||
|     if (this.checkRowFees(fit.y, tx.feerate)) { | ||||
|       // insert the tx into rows at that position
 | ||||
|       for (let y = fit.y; y < fit.y + size; y++) { | ||||
|         if (y >= this.rows.length) { | ||||
|           this.addRow(); | ||||
|         } | ||||
|         this.rows[y].insert(fit.x, size, tx); | ||||
|       } | ||||
|       const position = { x: fit.x, y: fit.y, s: size }; | ||||
|       this.txPositions[tx.txid] = position; | ||||
|       this.txs[tx.txid] = tx; | ||||
|       tx.applyGridPosition(position); | ||||
|       return position; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Return the first slot with a lower feerate
 | ||||
|   getReplacementRoot(feerate: number, width: number): Square { | ||||
|     let slot; | ||||
|     for (let row = 0; row <= this.rows.length; row++) { | ||||
|       if (this.rows[row].slots.length > 0) { | ||||
|         return { x: this.rows[row].slots[0].l, y: row }; | ||||
|       } else { | ||||
|         slot = this.rows[row].filled.find(x => { | ||||
|           return x.tx.feerate < feerate; | ||||
|         }); | ||||
|         if (slot) { | ||||
|           return { x: Math.min(slot.l, this.width - width), y: row }; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return { x: 0, y: this.rows.length }; | ||||
|   } | ||||
| 
 | ||||
|   // remove and return all transactions in a subtree of the layout
 | ||||
|   popTree(x: number, y: number, width: number) { | ||||
|     const selected: { [key: string]: TxView } = {}; | ||||
|     let left = x; | ||||
|     let right = x + width; | ||||
|     let prevWidth = right - left; | ||||
|     let prevFee = Infinity; | ||||
|     // scan rows upwards within a channel bounded by 'left' and 'right'
 | ||||
|     for (let row = y; row < this.rows.length; row++) { | ||||
|       let rowMax = 0; | ||||
|       const slots = this.rows[row].getSlotsBetween(left, right); | ||||
|       // check each slot in this row overlapping the search channel
 | ||||
|       slots.forEach(slot => { | ||||
|         // select the associated transaction
 | ||||
|         selected[slot.tx.txid] = slot.tx; | ||||
|         rowMax = Math.max(rowMax, slot.tx.feerate); | ||||
|         // widen the search channel to accommodate this slot if necessary
 | ||||
|         if (slot.w > prevWidth) { | ||||
|           left = slot.l; | ||||
|           right = slot.r; | ||||
|           // if this slot's tx has a higher feerate than the max in the previous row
 | ||||
|           // (i.e. it's out of position)
 | ||||
|           // select all txs overlapping the slot's full width in some rows *below*
 | ||||
|           // to free up space for this tx to sink down to its proper position
 | ||||
|           if (slot.tx.feerate > prevFee) { | ||||
|             let count = 0; | ||||
|             // keep scanning back down until we find a full row of higher-feerate txs
 | ||||
|             for (let echo = row - 1; echo >= 0 && count < slot.w; echo--) { | ||||
|               const echoSlots = this.rows[echo].getSlotsBetween(slot.l, slot.r); | ||||
|               count = 0; | ||||
|               echoSlots.forEach(echoSlot => { | ||||
|                 selected[echoSlot.tx.txid] = echoSlot.tx; | ||||
|                 if (echoSlot.tx.feerate >= slot.tx.feerate) { | ||||
|                   count += echoSlot.w; | ||||
|                 } | ||||
|               }); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
|       prevWidth = right - left; | ||||
|       prevFee = rowMax; | ||||
|     } | ||||
| 
 | ||||
|     const txList = Object.values(selected); | ||||
| 
 | ||||
|     txList.forEach(tx => { | ||||
|       this.remove(tx); | ||||
|     }); | ||||
|     return txList; | ||||
|   } | ||||
| 
 | ||||
|   // Check if this row has high enough avg fees
 | ||||
|   // for a tx with this feerate to make sense here
 | ||||
|   checkRowFees(row: number, targetFee: number): boolean { | ||||
|     // first row is always fine
 | ||||
|     if (row === 0 || !this.rows[row]) { | ||||
|       return true; | ||||
|     } | ||||
|     return (this.rows[row].getAvgFeerate() > (targetFee * 0.9)); | ||||
|   } | ||||
| 
 | ||||
|   // drop any free-floating transactions down into empty spaces
 | ||||
|   applyGravity(): void { | ||||
|     Object.entries(this.txPositions).sort(([keyA, posA], [keyB, posB]) => { | ||||
|       return posA.y - posB.y || posA.x - posB.x; | ||||
|     }).forEach(([txid, position]) => { | ||||
|       // see how far this transaction can fall
 | ||||
|       let dropTo = position.y; | ||||
|       while (dropTo > 0 && !this.rows[dropTo - 1].getSlotsBetween(position.x, position.x + position.s).length) { | ||||
|         dropTo--; | ||||
|       } | ||||
|       // if it can fall at all
 | ||||
|       if (dropTo < position.y) { | ||||
|         // remove and reinsert in the row we found
 | ||||
|         const tx = this.txs[txid]; | ||||
|         this.remove(tx); | ||||
|         this.insert(tx, position.s); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function feeRateDescending(a: TxView, b: TxView) { | ||||
|   return b.feerate - a.feerate; | ||||
| } | ||||
| @ -18,6 +18,7 @@ export class FastVertexArray { | ||||
|   data: Float32Array; | ||||
|   freeSlots: number[]; | ||||
|   lastSlot: number; | ||||
|   dirty = false; | ||||
| 
 | ||||
|   constructor(length, stride) { | ||||
|     this.length = length; | ||||
| @ -27,6 +28,7 @@ export class FastVertexArray { | ||||
|     this.data = new Float32Array(this.length * this.stride); | ||||
|     this.freeSlots = []; | ||||
|     this.lastSlot = 0; | ||||
|     this.dirty = true; | ||||
|   } | ||||
| 
 | ||||
|   insert(sprite: TxSprite): number { | ||||
| @ -44,6 +46,7 @@ export class FastVertexArray { | ||||
|     } | ||||
|     this.sprites[position] = sprite; | ||||
|     return position; | ||||
|     this.dirty = true; | ||||
|   } | ||||
| 
 | ||||
|   remove(index: number): void { | ||||
| @ -54,14 +57,17 @@ export class FastVertexArray { | ||||
|     if (this.length > 2048 && this.count < (this.length * 0.4)) { | ||||
|       this.compact(); | ||||
|     } | ||||
|     this.dirty = true; | ||||
|   } | ||||
| 
 | ||||
|   setData(index: number, dataChunk: number[]): void { | ||||
|     this.data.set(dataChunk, (index * this.stride)); | ||||
|     this.dirty = true; | ||||
|   } | ||||
| 
 | ||||
|   clearData(index: number): void { | ||||
|     this.data.fill(0, (index * this.stride), ((index + 1) * this.stride)); | ||||
|     this.dirty = true; | ||||
|   } | ||||
| 
 | ||||
|   getData(index: number): Float32Array { | ||||
| @ -73,6 +79,7 @@ export class FastVertexArray { | ||||
|     const newData = new Float32Array(this.length * this.stride); | ||||
|     newData.set(this.data); | ||||
|     this.data = newData; | ||||
|     this.dirty = true; | ||||
|   } | ||||
| 
 | ||||
|   compact(): void { | ||||
| @ -97,6 +104,7 @@ export class FastVertexArray { | ||||
|       this.freeSlots = []; | ||||
|       this.lastSlot = i; | ||||
|     } | ||||
|     this.dirty = true; | ||||
|   } | ||||
| 
 | ||||
|   getVertexData(): Float32Array { | ||||
| @ -82,8 +82,10 @@ export default class TxView implements TransactionStripped { | ||||
|     delay: additional milliseconds to wait before starting | ||||
|     jitter: if set, adds a random amount to the delay, | ||||
|     adjust: if true, modify an in-progress transition instead of replacing it | ||||
| 
 | ||||
|     returns minimum transition end time | ||||
|   */ | ||||
|   update(params: ViewUpdateParams): void { | ||||
|   update(params: ViewUpdateParams): number { | ||||
|     if (params.jitter) { | ||||
|       params.delay += (Math.random() * params.jitter); | ||||
|     } | ||||
| @ -96,6 +98,7 @@ export default class TxView implements TransactionStripped { | ||||
|       ); | ||||
|       // apply any pending hover event
 | ||||
|       if (this.hover) { | ||||
|         params.duration = Math.max(params.duration, hoverTransitionTime); | ||||
|         this.sprite.update({ | ||||
|           ...this.hoverColor, | ||||
|           duration: hoverTransitionTime, | ||||
| @ -109,10 +112,12 @@ export default class TxView implements TransactionStripped { | ||||
|       ); | ||||
|     } | ||||
|     this.dirty = false; | ||||
|     return (params.start || performance.now()) + (params.delay || 0) + (params.duration || 0); | ||||
|   } | ||||
| 
 | ||||
|   // Temporarily override the tx color
 | ||||
|   setHover(hoverOn: boolean, color: Color | void = defaultHoverColor): void { | ||||
|   // returns minimum transition end time
 | ||||
|   setHover(hoverOn: boolean, color: Color | void = defaultHoverColor): number { | ||||
|     if (hoverOn) { | ||||
|       this.hover = true; | ||||
|       this.hoverColor = color; | ||||
| @ -131,6 +136,7 @@ export default class TxView implements TransactionStripped { | ||||
|       } | ||||
|     } | ||||
|     this.dirty = false; | ||||
|     return performance.now() + hoverTransitionTime; | ||||
|   } | ||||
| 
 | ||||
|   getColor(): Color { | ||||
| @ -0,0 +1,37 @@ | ||||
| <div | ||||
|   #tooltip | ||||
|   class="block-overview-tooltip" | ||||
|   [class.clickable]="clickable" | ||||
|   [style.visibility]="tx ? 'visible' : 'hidden'" | ||||
|   [style.left]="tooltipPosition.x + 'px'" | ||||
|   [style.top]="tooltipPosition.y + 'px'" | ||||
| > | ||||
|   <table> | ||||
|     <tbody> | ||||
|       <tr> | ||||
|         <td i18n="shared.transaction">Transaction</td> | ||||
|         <td> | ||||
|           <a [routerLink]="['/tx/' | relativeUrl, txid]">{{ txid | shortenString : 16}}</a> | ||||
|         </td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.value|Transaction value">Value</td> | ||||
|         <td><app-amount [satoshis]="value"></app-amount></td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td> | ||||
|         <td>{{ fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span>   <span class="fiat"><app-fiat [value]="fee"></app-fiat></span></td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td> | ||||
|         <td> | ||||
|           {{ feeRate | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> | ||||
|         </td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td> | ||||
|         <td [innerHTML]="'‎' + (vsize | vbytes: 2)"></td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| </div> | ||||
| @ -0,0 +1,18 @@ | ||||
| .block-overview-tooltip { | ||||
|   position: absolute; | ||||
|   background: rgba(#11131f, 0.95); | ||||
|   border-radius: 4px; | ||||
|   box-shadow: 1px 1px 10px rgba(0,0,0,0.5); | ||||
|   color: #b1b1b1; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   justify-content: space-between; | ||||
|   padding: 10px 15px; | ||||
|   text-align: left; | ||||
|   width: 320px; | ||||
|   pointer-events: none; | ||||
| 
 | ||||
|   &.clickable { | ||||
|     pointer-events: all; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,53 @@ | ||||
| import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core'; | ||||
| import { TransactionStripped } from 'src/app/interfaces/websocket.interface'; | ||||
| import { Position } from 'src/app/components/block-overview-graph/sprite-types.js'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-overview-tooltip', | ||||
|   templateUrl: './block-overview-tooltip.component.html', | ||||
|   styleUrls: ['./block-overview-tooltip.component.scss'], | ||||
| }) | ||||
| export class BlockOverviewTooltipComponent implements OnChanges { | ||||
|   @Input() tx: TransactionStripped | void; | ||||
|   @Input() cursorPosition: Position; | ||||
|   @Input() clickable: boolean; | ||||
| 
 | ||||
|   txid = ''; | ||||
|   fee = 0; | ||||
|   value = 0; | ||||
|   vsize = 1; | ||||
|   feeRate = 0; | ||||
| 
 | ||||
|   tooltipPosition: Position = { x: 0, y: 0 }; | ||||
| 
 | ||||
|   @ViewChild('tooltip') tooltipElement: ElementRef<HTMLCanvasElement>; | ||||
| 
 | ||||
|   constructor() {} | ||||
| 
 | ||||
|   ngOnChanges(changes): void { | ||||
|     if (changes.cursorPosition && changes.cursorPosition.currentValue) { | ||||
|       let x = changes.cursorPosition.currentValue.x + 10; | ||||
|       let y = changes.cursorPosition.currentValue.y + 10; | ||||
|       if (this.tooltipElement) { | ||||
|         const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect(); | ||||
|         const parentBounds = this.tooltipElement.nativeElement.offsetParent.getBoundingClientRect(); | ||||
|         if ((parentBounds.left + x + elementBounds.width) > parentBounds.right) { | ||||
|           x = Math.max(0, parentBounds.width - elementBounds.width - 10); | ||||
|         } | ||||
|         if (y + elementBounds.height > parentBounds.height) { | ||||
|           y = y - elementBounds.height - 20; | ||||
|         } | ||||
|       } | ||||
|       this.tooltipPosition = { x, y }; | ||||
|     } | ||||
| 
 | ||||
|     if (changes.tx) { | ||||
|       const tx = changes.tx.currentValue || {}; | ||||
|       this.txid = tx.txid || ''; | ||||
|       this.fee = tx.fee || 0; | ||||
|       this.value = tx.value || 0; | ||||
|       this.vsize = tx.vsize || 1; | ||||
|       this.feeRate = this.fee / this.vsize; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -40,10 +40,11 @@ | ||||
| 
 | ||||
|   <div class="clearfix"></div> | ||||
| 
 | ||||
|   <ng-template [ngIf]="!isLoadingBlock && !error"> | ||||
| 
 | ||||
|     <div class="box"> | ||||
|       <div class="row"> | ||||
| 
 | ||||
|   <div class="box" *ngIf="!error"> | ||||
|     <div class="row"> | ||||
|       <ng-template [ngIf]="!isLoadingBlock"> | ||||
|         <div class="col-sm"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
| @ -68,73 +69,192 @@ | ||||
|                 <td i18n="block.weight">Weight</td> | ||||
|                 <td [innerHTML]="'‎' + (block.weight | wuBytes: 2)"></td> | ||||
|               </tr> | ||||
|               <ng-template [ngIf]="webGlEnabled"> | ||||
|                 <tr *ngIf="block?.extras?.medianFee != undefined"> | ||||
|                   <td class="td-width" i18n="block.median-fee">Median fee</td> | ||||
|                   <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> | ||||
|                 </tr> | ||||
|                 <ng-template [ngIf]="fees !== undefined" [ngIfElse]="loadingFees"> | ||||
|                   <tr> | ||||
|                     <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|                     <td *ngIf="network !== 'liquid' && network !== 'liquidtestnet'; else liquidTotalFees"> | ||||
|                       <app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
|                       <span class="fiat"> | ||||
|                         <app-fiat [value]="block.extras.totalFees" digitsInfo="1.0-0"></app-fiat> | ||||
|                       </span> | ||||
|                     </td> | ||||
|                     <ng-template #liquidTotalFees> | ||||
|                       <td> | ||||
|                         <app-amount [satoshis]="fees * 100000000" digitsInfo="1.2-2" [noFiat]="true"></app-amount>  <app-fiat | ||||
|                           [value]="fees * 100000000" digitsInfo="1.2-2"></app-fiat> | ||||
|                       </td> | ||||
|                     </ng-template> | ||||
|                   </tr> | ||||
|                   <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                     <td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td> | ||||
|                     <td> | ||||
|                       <app-amount [satoshis]="block.extras.reward" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
|                       <span class="fiat"> | ||||
|                         <app-fiat [value]="(blockSubsidy + fees) * 100000000" digitsInfo="1.0-0"></app-fiat> | ||||
|                       </span> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                 </ng-template> | ||||
|                 <ng-template #loadingFees> | ||||
|                   <tr> | ||||
|                     <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|                     <td style="width: 75%;"><span class="skeleton-loader"></span></td> | ||||
|                   </tr> | ||||
|                   <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                     <td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td> | ||||
|                     <td><span class="skeleton-loader"></span></td> | ||||
|                   </tr> | ||||
|                 </ng-template> | ||||
|                 <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                   <td i18n="block.miner">Miner</td> | ||||
|                   <td *ngIf="stateService.env.MINING_DASHBOARD"> | ||||
|                     <a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" | ||||
|                       [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> | ||||
|                       {{ block.extras.pool.name }} | ||||
|                     </a> | ||||
|                   </td> | ||||
|                   <td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'"> | ||||
|                     <span placement="bottom" class="badge" | ||||
|                       [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> | ||||
|                       {{ block.extras.pool.name }} | ||||
|                   </span> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               </ng-template> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </ng-template> | ||||
|       <ng-template [ngIf]="isLoadingBlock"> | ||||
|         <div class="col-sm"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
|               <tr *ngIf="block?.extras?.medianFee != undefined"> | ||||
|                 <td class="td-width" i18n="block.median-fee">Median fee</td> | ||||
|                 <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> | ||||
|               <tr> | ||||
|                 <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <ng-template [ngIf]="fees !== undefined" [ngIfElse]="loadingFees"> | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <ng-template [ngIf]="webGlEnabled"> | ||||
|                 <tr> | ||||
|                   <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|                   <td *ngIf="network !== 'liquid' && network !== 'liquidtestnet'; else liquidTotalFees"> | ||||
|                     <app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
|                     <span class="fiat"> | ||||
|                       <app-fiat [value]="block.extras.totalFees" digitsInfo="1.0-0"></app-fiat> | ||||
|                     </span> | ||||
|                   </td> | ||||
|                   <ng-template #liquidTotalFees> | ||||
|                     <td> | ||||
|                       <app-amount [satoshis]="fees * 100000000" digitsInfo="1.2-2" [noFiat]="true"></app-amount>  <app-fiat | ||||
|                         [value]="fees * 100000000" digitsInfo="1.2-2"></app-fiat> | ||||
|                     </td> | ||||
|                   </ng-template> | ||||
|                   <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
|                 <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                   <td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td> | ||||
|                   <td> | ||||
|                     <app-amount [satoshis]="block.extras.reward" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
|                     <span class="fiat"> | ||||
|                       <app-fiat [value]="(blockSubsidy + fees) * 100000000" digitsInfo="1.0-0"></app-fiat> | ||||
|                     </span> | ||||
|                   </td> | ||||
|                 <tr> | ||||
|                   <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                   <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                   <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
|               </ng-template> | ||||
|               <ng-template #loadingFees> | ||||
|                 <tr> | ||||
|                   <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|                   <td style="width: 75%;"><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
|                 <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                   <td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td> | ||||
|                   <td><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
|               </ng-template> | ||||
|               <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                 <td i18n="block.miner">Miner</td> | ||||
|                 <td *ngIf="stateService.env.MINING_DASHBOARD"> | ||||
|                   <a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" | ||||
|                     [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> | ||||
|                     {{ block.extras.pool.name }} | ||||
|                   </a> | ||||
|                 </td> | ||||
|                 <td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'"> | ||||
|                   <span placement="bottom" class="badge" | ||||
|                     [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> | ||||
|                     {{ block.extras.pool.name }} | ||||
|                 </span> | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </ng-template> | ||||
|       <div class="col-sm" *ngIf="!webGlEnabled"> | ||||
|         <table class="table table-borderless table-striped" *ngIf="!isLoadingBlock"> | ||||
|           <tbody> | ||||
|             <tr *ngIf="block?.extras?.medianFee != undefined"> | ||||
|               <td class="td-width" i18n="block.median-fee">Median fee</td> | ||||
|               <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> | ||||
|             </tr> | ||||
|             <ng-template [ngIf]="fees !== undefined" [ngIfElse]="loadingFees"> | ||||
|               <tr> | ||||
|                 <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|                 <td *ngIf="network !== 'liquid' && network !== 'liquidtestnet'; else liquidTotalFees"> | ||||
|                   <app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
|                   <span class="fiat"> | ||||
|                     <app-fiat [value]="block.extras.totalFees" digitsInfo="1.0-0"></app-fiat> | ||||
|                   </span> | ||||
|                 </td> | ||||
|                 <ng-template #liquidTotalFees> | ||||
|                   <td> | ||||
|                     <app-amount [satoshis]="fees * 100000000" digitsInfo="1.2-2" [noFiat]="true"></app-amount>  <app-fiat | ||||
|                       [value]="fees * 100000000" digitsInfo="1.2-2"></app-fiat> | ||||
|                   </td> | ||||
|                 </ng-template> | ||||
|               </tr> | ||||
|               <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                 <td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td> | ||||
|                 <td> | ||||
|                   <app-amount [satoshis]="block.extras.reward" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
|                   <span class="fiat"> | ||||
|                     <app-fiat [value]="(blockSubsidy + fees) * 100000000" digitsInfo="1.0-0"></app-fiat> | ||||
|                   </span> | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </ng-template> | ||||
|             <ng-template #loadingFees> | ||||
|               <tr> | ||||
|                 <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|                 <td style="width: 75%;"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                 <td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td> | ||||
|                 <td><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|             </ng-template> | ||||
|             <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|               <td i18n="block.miner">Miner</td> | ||||
|               <td *ngIf="stateService.env.MINING_DASHBOARD"> | ||||
|                 <a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" | ||||
|                   [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> | ||||
|                   {{ block.extras.pool.name }} | ||||
|                 </a> | ||||
|               </td> | ||||
|               <td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'"> | ||||
|                 <span placement="bottom" class="badge" | ||||
|                   [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> | ||||
|                   {{ block.extras.pool.name }} | ||||
|               </span> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|         <table class="table table-borderless table-striped" *ngIf="isLoadingBlock"> | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|       <div class="col-sm chart-container" *ngIf="webGlEnabled"> | ||||
|         <app-block-overview-graph | ||||
|           #blockGraph | ||||
|           [isLoading]="isLoadingOverview" | ||||
|           [resolution]="75" | ||||
|           [blockLimit]="stateService.blockVSize" | ||||
|           [orientation]="'top'" | ||||
|           [flip]="false" | ||||
|           (txClickEvent)="onTxClick($event)" | ||||
|         ></app-block-overview-graph> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|   </div> | ||||
|   <ng-template [ngIf]="!isLoadingBlock && !error"> | ||||
|     <div [hidden]="!showDetails" id="details"> | ||||
|       <br> | ||||
| 
 | ||||
| @ -223,63 +343,17 @@ | ||||
|           <div class="row"> | ||||
|             <div class="col-sm"> | ||||
|               <span class="skeleton-loader"></span> | ||||
|               <span class="skeleton-loader"></span> | ||||
|             </div> | ||||
|             <div class="col-sm"> | ||||
|               <span class="skeleton-loader"></span> | ||||
|               <span class="skeleton-loader"></span> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </ng-template> | ||||
|     <ngb-pagination class="pagination-container float-right" [collectionSize]="block.tx_count" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination> | ||||
| 
 | ||||
|   </ng-template> | ||||
| 
 | ||||
|   <ng-template [ngIf]="isLoadingBlock && !error"> | ||||
| 
 | ||||
|     <div class="box"> | ||||
|       <div class="row"> | ||||
|         <div class="col-sm"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
|               <tr> | ||||
|                 <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|         <div class="col-sm"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
|               <tr> | ||||
|                 <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </ng-template> | ||||
| 
 | ||||
|   <ng-template [ngIf]="error"> | ||||
|     <div class="text-center"> | ||||
|       <span i18n="error.general-loading-data">Error loading data.</span> | ||||
|  | ||||
| @ -148,3 +148,10 @@ h1 { | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .chart-container{ | ||||
|   margin: 20px auto; | ||||
|   @media (min-width: 768px) { | ||||
|     margin: auto; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -2,15 +2,16 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/co | ||||
| import { Location } from '@angular/common'; | ||||
| import { ActivatedRoute, ParamMap, Router } from '@angular/router'; | ||||
| import { ElectrsApiService } from '../../services/electrs-api.service'; | ||||
| import { switchMap, tap, debounceTime, catchError, map } from 'rxjs/operators'; | ||||
| import { switchMap, tap, debounceTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators'; | ||||
| import { Transaction, Vout } from '../../interfaces/electrs.interface'; | ||||
| import { Observable, of, Subscription } from 'rxjs'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { SeoService } from 'src/app/services/seo.service'; | ||||
| import { WebsocketService } from 'src/app/services/websocket.service'; | ||||
| import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { BlockExtended } from 'src/app/interfaces/node-api.interface'; | ||||
| import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block', | ||||
| @ -21,6 +22,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|   network = ''; | ||||
|   block: BlockExtended; | ||||
|   blockHeight: number; | ||||
|   lastBlockHeight: number; | ||||
|   nextBlockHeight: number; | ||||
|   blockHash: string; | ||||
|   isLoadingBlock = true; | ||||
| @ -28,6 +30,10 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|   latestBlocks: BlockExtended[] = []; | ||||
|   transactions: Transaction[]; | ||||
|   isLoadingTransactions = true; | ||||
|   strippedTransactions: TransactionStripped[]; | ||||
|   overviewTransitionDirection: string; | ||||
|   isLoadingOverview = true; | ||||
|   isAwaitingOverview = true; | ||||
|   error: any; | ||||
|   blockSubsidy: number; | ||||
|   fees: number; | ||||
| @ -39,13 +45,18 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|   showPreviousBlocklink = true; | ||||
|   showNextBlocklink = true; | ||||
|   transactionsError: any = null; | ||||
|   overviewError: any = null; | ||||
|   webGlEnabled = true; | ||||
| 
 | ||||
|   subscription: Subscription; | ||||
|   transactionSubscription: Subscription; | ||||
|   overviewSubscription: Subscription; | ||||
|   keyNavigationSubscription: Subscription; | ||||
|   blocksSubscription: Subscription; | ||||
|   networkChangedSubscription: Subscription; | ||||
|   queryParamsSubscription: Subscription; | ||||
| 
 | ||||
|   @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; | ||||
| 
 | ||||
|   constructor( | ||||
|     private route: ActivatedRoute, | ||||
|     private location: Location, | ||||
| @ -56,7 +67,9 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|     private websocketService: WebsocketService, | ||||
|     private relativeUrlPipe: RelativeUrlPipe, | ||||
|     private apiService: ApiService | ||||
|   ) { } | ||||
|   ) { | ||||
|     this.webGlEnabled = detectWebGL(); | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit() { | ||||
|     this.websocketService.want(['blocks', 'mempool-blocks']); | ||||
| @ -85,7 +98,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|     this.subscription = this.route.paramMap.pipe( | ||||
|     const block$ = this.route.paramMap.pipe( | ||||
|       switchMap((params: ParamMap) => { | ||||
|         const blockHash: string = params.get('id') || ''; | ||||
|         this.block = undefined; | ||||
| @ -141,6 +154,8 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       tap((block: BlockExtended) => { | ||||
|         this.block = block; | ||||
|         this.blockHeight = block.height; | ||||
|         const direction = (this.lastBlockHeight < this.blockHeight) ? 'right' : 'left'; | ||||
|         this.lastBlockHeight = this.blockHeight; | ||||
|         this.nextBlockHeight = block.height + 1; | ||||
|         this.setNextAndPreviousBlockLink(); | ||||
| 
 | ||||
| @ -154,8 +169,17 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|         this.isLoadingTransactions = true; | ||||
|         this.transactions = null; | ||||
|         this.transactionsError = null; | ||||
|         this.isLoadingOverview = true; | ||||
|         this.isAwaitingOverview = true; | ||||
|         this.overviewError = true; | ||||
|         if (this.blockGraph) { | ||||
|           this.blockGraph.exit(direction); | ||||
|         } | ||||
|       }), | ||||
|       debounceTime(300), | ||||
|       shareReplay(1) | ||||
|     ); | ||||
|     this.transactionSubscription = block$.pipe( | ||||
|       switchMap((block) => this.electrsApiService.getBlockTransactions$(block.id) | ||||
|         .pipe( | ||||
|           catchError((err) => { | ||||
| @ -170,10 +194,50 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       } | ||||
|       this.transactions = transactions; | ||||
|       this.isLoadingTransactions = false; | ||||
| 
 | ||||
|       if (!this.isAwaitingOverview && this.blockGraph && this.strippedTransactions && this.overviewTransitionDirection) { | ||||
|         this.isLoadingOverview = false; | ||||
|         this.blockGraph.replace(this.strippedTransactions, this.overviewTransitionDirection, false); | ||||
|       } | ||||
|     }, | ||||
|     (error) => { | ||||
|       this.error = error; | ||||
|       this.isLoadingBlock = false; | ||||
|       this.isLoadingOverview = false; | ||||
|     }); | ||||
| 
 | ||||
|     this.overviewSubscription = block$.pipe( | ||||
|       startWith(null), | ||||
|       pairwise(), | ||||
|       switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id) | ||||
|         .pipe( | ||||
|           catchError((err) => { | ||||
|             this.overviewError = err; | ||||
|             return of([]); | ||||
|           }), | ||||
|           switchMap((transactions) => { | ||||
|             if (prevBlock) { | ||||
|               return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' }); | ||||
|             } else { | ||||
|               return of({ transactions, direction: 'down' }); | ||||
|             } | ||||
|           }) | ||||
|         ) | ||||
|       ), | ||||
|     ) | ||||
|     .subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { | ||||
|       this.isAwaitingOverview = false; | ||||
|       this.strippedTransactions = transactions; | ||||
|       this.overviewTransitionDirection = direction; | ||||
|       if (!this.isLoadingTransactions && this.blockGraph) { | ||||
|         this.isLoadingOverview = false; | ||||
|         this.blockGraph.replace(this.strippedTransactions, this.overviewTransitionDirection, false); | ||||
|       } | ||||
|     }, | ||||
|     (error) => { | ||||
|       this.error = error; | ||||
|       this.isLoadingOverview = false; | ||||
|       this.isAwaitingOverview = false; | ||||
|     }); | ||||
| 
 | ||||
|     this.networkChangedSubscription = this.stateService.networkChanged$ | ||||
| @ -203,7 +267,8 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
| 
 | ||||
|   ngOnDestroy() { | ||||
|     this.stateService.markBlock$.next({}); | ||||
|     this.subscription.unsubscribe(); | ||||
|     this.transactionSubscription.unsubscribe(); | ||||
|     this.overviewSubscription.unsubscribe(); | ||||
|     this.keyNavigationSubscription.unsubscribe(); | ||||
|     this.blocksSubscription.unsubscribe(); | ||||
|     this.networkChangedSubscription.unsubscribe(); | ||||
| @ -302,4 +367,15 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onTxClick(event: TransactionStripped): void { | ||||
|     const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); | ||||
|     this.router.navigate([url]); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function detectWebGL() { | ||||
|   const canvas = document.createElement('canvas'); | ||||
|   const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); | ||||
|   return (gl && gl instanceof WebGLRenderingContext); | ||||
| } | ||||
|  | ||||
| @ -1,684 +0,0 @@ | ||||
| import { FastVertexArray } from './fast-vertex-array' | ||||
| import TxView from './tx-view' | ||||
| import { TransactionStripped } from 'src/app/interfaces/websocket.interface'; | ||||
| import { Position, Square } from './sprite-types' | ||||
| 
 | ||||
| export default class BlockScene { | ||||
|   scene: { count: number, offset: { x: number, y: number}}; | ||||
|   vertexArray: FastVertexArray; | ||||
|   txs: { [key: string]: TxView }; | ||||
|   width: number; | ||||
|   height: number; | ||||
|   gridWidth: number; | ||||
|   gridHeight: number; | ||||
|   gridSize: number; | ||||
|   vbytesPerUnit: number; | ||||
|   unitPadding: number; | ||||
|   unitWidth: number; | ||||
|   initialised: boolean; | ||||
|   layout: BlockLayout; | ||||
|   dirty: boolean; | ||||
| 
 | ||||
|   constructor ({ width, height, resolution, blockLimit, vertexArray }: { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray }) { | ||||
|     this.init({ width, height, resolution, blockLimit, vertexArray }) | ||||
|   } | ||||
| 
 | ||||
|   destroy (): void { | ||||
|     Object.values(this.txs).forEach(tx => tx.destroy()) | ||||
|   } | ||||
| 
 | ||||
|   resize({ width = this.width, height = this.height }: { width?: number, height?: number}): void { | ||||
|     this.width = width; | ||||
|     this.height = height; | ||||
|     this.gridSize = this.width / this.gridWidth; | ||||
|     this.unitPadding =  width / 500; | ||||
|     this.unitWidth = this.gridSize - (this.unitPadding * 2); | ||||
| 
 | ||||
|     this.dirty = true; | ||||
|     if (this.initialised && this.scene) { | ||||
|       this.updateAll(performance.now()); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Animate new block entering scene
 | ||||
|   enter (txs: TransactionStripped[], direction) { | ||||
|     this.replace(txs, direction) | ||||
|   } | ||||
| 
 | ||||
|   // Animate block leaving scene
 | ||||
|   exit (direction: string): void { | ||||
|     const startTime = performance.now() | ||||
|     const removed = this.removeBatch(Object.keys(this.txs), startTime, direction) | ||||
| 
 | ||||
|     // clean up sprites
 | ||||
|     setTimeout(() => { | ||||
|       removed.forEach(tx => { | ||||
|         tx.destroy() | ||||
|       }) | ||||
|     }, 2000) | ||||
|   } | ||||
| 
 | ||||
|   // Reset layout and replace with new set of transactions
 | ||||
|   replace (txs: TransactionStripped[], direction: string = 'left'): void { | ||||
|     const startTime = performance.now() | ||||
|     const nextIds = {} | ||||
|     const remove = [] | ||||
|     txs.forEach(tx => { | ||||
|       nextIds[tx.txid] = true | ||||
|     }) | ||||
|     Object.keys(this.txs).forEach(txid => { | ||||
|       if (!nextIds[txid]) remove.push(txid) | ||||
|     }) | ||||
|     txs.forEach(tx => { | ||||
|       if (!this.txs[tx.txid]) this.txs[tx.txid] = new TxView(tx, this.vertexArray) | ||||
|     }) | ||||
| 
 | ||||
|     const removed = this.removeBatch(remove, startTime, direction) | ||||
| 
 | ||||
|     // clean up sprites
 | ||||
|     setTimeout(() => { | ||||
|       removed.forEach(tx => { | ||||
|         tx.destroy() | ||||
|       }) | ||||
|     }, 1000) | ||||
| 
 | ||||
|     this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }) | ||||
| 
 | ||||
|     Object.values(this.txs).sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => { | ||||
|       this.place(tx) | ||||
|     }) | ||||
| 
 | ||||
|     this.updateAll(startTime, direction) | ||||
|   } | ||||
| 
 | ||||
|   update (add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { | ||||
|     const startTime = performance.now() | ||||
|     const removed = this.removeBatch(remove, startTime, direction) | ||||
| 
 | ||||
|     // clean up sprites
 | ||||
|     setTimeout(() => { | ||||
|       removed.forEach(tx => { | ||||
|         tx.destroy() | ||||
|       }) | ||||
|     }, 1000) | ||||
| 
 | ||||
|     if (resetLayout) { | ||||
|       add.forEach(tx => { | ||||
|         if (!this.txs[tx.txid]) this.txs[tx.txid] = new TxView(tx, this.vertexArray) | ||||
|       }) | ||||
|       this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }) | ||||
|       Object.values(this.txs).sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => { | ||||
|         this.place(tx) | ||||
|       }) | ||||
|     } else { | ||||
|       // try to insert new txs directly
 | ||||
|       const remaining = [] | ||||
|       add.map(tx => new TxView(tx, this.vertexArray)).sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => { | ||||
|         if (!this.tryInsertByFee(tx)) { | ||||
|           remaining.push(tx) | ||||
|         } | ||||
|       }) | ||||
|       this.placeBatch(remaining) | ||||
|       this.layout.applyGravity() | ||||
|     } | ||||
| 
 | ||||
|     this.updateAll(startTime, direction) | ||||
|   } | ||||
| 
 | ||||
|   //return the tx at this screen position, if any
 | ||||
|   getTxAt (position: Position): TxView | void { | ||||
|     if (this.layout) { | ||||
|       const gridPosition = this.screenToGrid(position) | ||||
|       return this.layout.getTx(gridPosition) | ||||
|     } else return null | ||||
|   } | ||||
| 
 | ||||
|   private init ({ width, height, resolution, blockLimit, vertexArray }: { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray }): void { | ||||
|     this.vertexArray = vertexArray | ||||
| 
 | ||||
|     this.scene = { | ||||
|       count: 0, | ||||
|       offset: { | ||||
|         x: 0, | ||||
|         y: 0 | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Set the scale of the visualization (with a 5% margin)
 | ||||
|     this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.05, 2) | ||||
|     this.gridWidth = resolution | ||||
|     this.gridHeight = resolution | ||||
|     this.resize({ width, height }) | ||||
|     this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }) | ||||
| 
 | ||||
|     this.txs = {} | ||||
| 
 | ||||
|     this.initialised = true | ||||
|     this.dirty = true | ||||
|   } | ||||
| 
 | ||||
|   private insert (tx: TxView, startTime: number, direction: string = 'left'): void { | ||||
|     this.txs[tx.txid] = tx | ||||
|     this.place(tx) | ||||
|     this.updateTx(tx, startTime, direction) | ||||
|   } | ||||
| 
 | ||||
|   private updateTx (tx: TxView, startTime: number, direction: string = 'left'): void { | ||||
|     if (tx.dirty || this.dirty) { | ||||
|       this.saveGridToScreenPosition(tx) | ||||
|       this.setTxOnScreen(tx, startTime, direction) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private setTxOnScreen (tx: TxView, startTime: number, direction: string = 'left'): void { | ||||
|     if (!tx.initialised) { | ||||
|       const txColor = tx.getColor() | ||||
|       tx.update({ | ||||
|         display: { | ||||
|           position: { | ||||
|             x: tx.screenPosition.x + (direction == 'right' ? -this.width : this.width) * 1.4, | ||||
|             y: tx.screenPosition.y, | ||||
|             s: tx.screenPosition.s | ||||
|           }, | ||||
|           color: txColor, | ||||
|         }, | ||||
|         start: startTime, | ||||
|         delay: 0, | ||||
|       }) | ||||
|       tx.update({ | ||||
|         display: { | ||||
|           position: tx.screenPosition, | ||||
|           color: txColor | ||||
|         }, | ||||
|         duration: 1000, | ||||
|         start: startTime, | ||||
|         delay: 50, | ||||
|       }) | ||||
|     } else { | ||||
|       tx.update({ | ||||
|         display: { | ||||
|           position: tx.screenPosition | ||||
|         }, | ||||
|         duration: 1000, | ||||
|         minDuration: 500, | ||||
|         start: startTime, | ||||
|         delay: 50, | ||||
|         adjust: true | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private updateAll (startTime: number, direction: string = 'left'): void { | ||||
|     this.scene.count = 0 | ||||
|     const ids = this.getTxList() | ||||
|     startTime = startTime || performance.now() | ||||
|     for (let i = 0; i < ids.length; i++) { | ||||
|       this.updateTx(this.txs[ids[i]], startTime, direction) | ||||
|     } | ||||
|     this.dirty = false | ||||
|   } | ||||
| 
 | ||||
|   private remove (id: string, startTime: number, direction: string = 'left'): TxView | void { | ||||
|     const tx = this.txs[id] | ||||
|     if (tx) { | ||||
|       this.layout.remove(tx) | ||||
|       tx.update({ | ||||
|         display: { | ||||
|           position: { | ||||
|             x: tx.screenPosition.x + (direction == 'right' ? this.width : -this.width) * 1.4, | ||||
|             y: this.txs[id].screenPosition.y, | ||||
|           } | ||||
|         }, | ||||
|         duration: 1000, | ||||
|         start: startTime, | ||||
|         delay: 50 | ||||
|       }) | ||||
|     } | ||||
|     delete this.txs[id] | ||||
|     return tx | ||||
|   } | ||||
| 
 | ||||
|   private getTxList (): string[] { | ||||
|     return Object.keys(this.txs) | ||||
|   } | ||||
| 
 | ||||
|   private saveGridToScreenPosition (tx: TxView): void { | ||||
|     tx.screenPosition = this.gridToScreen(tx.gridPosition) | ||||
|   } | ||||
| 
 | ||||
|   // convert grid coordinates to screen coordinates
 | ||||
|   private gridToScreen (position: Square | void): Square { | ||||
|     if (position) { | ||||
|       const slotSize = (position.s * this.gridSize) | ||||
|       const squareSize = slotSize - (this.unitPadding * 2) | ||||
| 
 | ||||
|       // The grid is laid out notionally left-to-right, bottom-to-top
 | ||||
|       // So we rotate 90deg counterclockwise then flip the y axis
 | ||||
|       //
 | ||||
|       //    grid                             screen
 | ||||
|       //  ________          ________        ________
 | ||||
|       // |        |        |       b|      |       a|
 | ||||
|       // |        | rotate |        | flip |     c  |
 | ||||
|       // |  c     |   -->  |      c |  --> |        |
 | ||||
|       // |a______b|        |_______a|      |_______b|
 | ||||
|       return { | ||||
|         x: this.width + (this.unitPadding * 2) - (this.gridSize * position.y) - slotSize, | ||||
|         y: this.height - ((this.gridSize * position.x) + (slotSize - this.unitPadding)), | ||||
|         s: squareSize | ||||
|       } | ||||
|     } else { | ||||
|       return { x: 0, y: 0, s: 0 } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   screenToGrid (position: Position): Position { | ||||
|     const grid = { | ||||
|       x: Math.floor((position.y - this.unitPadding) / this.gridSize), | ||||
|       y: Math.floor((this.width + (this.unitPadding * 2) - position.x) / this.gridSize) | ||||
|     } | ||||
|     return grid | ||||
|   } | ||||
| 
 | ||||
|   // calculates and returns the size of the tx in multiples of the grid size
 | ||||
|   private txSize (tx: TxView): number { | ||||
|     let scale = Math.max(1,Math.round(Math.sqrt(tx.vsize / this.vbytesPerUnit))) | ||||
|     return Math.min(this.gridWidth, Math.max(1, scale)) // bound between 1 and the max displayable size (just in case!)
 | ||||
|   } | ||||
| 
 | ||||
|   private place (tx: TxView): void { | ||||
|     const size = this.txSize(tx) | ||||
|     this.layout.insert(tx, size) | ||||
|   } | ||||
| 
 | ||||
|   private tryInsertByFee (tx: TxView): boolean { | ||||
|     const size = this.txSize(tx) | ||||
|     const position = this.layout.tryInsertByFee(tx, size) | ||||
|     if (position) { | ||||
|       this.txs[tx.txid] = tx | ||||
|       return true | ||||
|     } else { | ||||
|       return false | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Add a list of transactions to the layout,
 | ||||
|   // keeping everything approximately sorted by feerate.
 | ||||
|   private placeBatch (txs: TxView[]): void { | ||||
|     if (txs.length) { | ||||
|       // grab the new tx with the highest fee rate
 | ||||
|       txs = txs.sort((a,b) => { return b.feerate - a.feerate }) | ||||
|       let i = 0 | ||||
|       let maxSize = txs.reduce((max, tx) => { | ||||
|         return Math.max(this.txSize(tx), max) | ||||
|       }, 1) * 2 | ||||
| 
 | ||||
|       // find a reasonable place for it in the layout
 | ||||
|       const root = this.layout.getReplacementRoot(txs[0].feerate, maxSize) | ||||
| 
 | ||||
|       // extract a sub tree of transactions from the layout, rooted at that point
 | ||||
|       const popped = this.layout.popTree(root.x, root.y, maxSize) | ||||
|       // combine those with the new transactions and sort
 | ||||
|       txs = txs.concat(popped) | ||||
|       txs = txs.sort((a,b) => { return b.feerate - a.feerate }) | ||||
| 
 | ||||
|       // insert everything back into the layout
 | ||||
|       txs.forEach(tx => { | ||||
|         this.txs[tx.txid] = tx | ||||
|         this.place(tx) | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private removeBatch (ids: string[], startTime: number, direction: string = 'left'): TxView[] { | ||||
|     if (!startTime) startTime = performance.now() | ||||
|     return ids.map(id => { | ||||
|       return this.remove(id, startTime, direction) | ||||
|     }).filter(tx => tx != null) as TxView[] | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| class Slot { | ||||
|   l: number | ||||
|   r: number | ||||
|   w: number | ||||
| 
 | ||||
|   constructor (l: number, r: number) { | ||||
|     this.l = l | ||||
|     this.r = r | ||||
|     this.w = r - l | ||||
|   } | ||||
| 
 | ||||
|   intersects (slot: Slot): boolean { | ||||
|     return !((slot.r <= this.l) || (slot.l >= this.r)) | ||||
|   } | ||||
| 
 | ||||
|   subtract (slot: Slot): Slot[] | void { | ||||
|     if (this.intersects(slot)) { | ||||
|       // from middle
 | ||||
|       if (slot.l > this.l && slot.r < this.r) { | ||||
|         return [ | ||||
|           new Slot(this.l, slot.l), | ||||
|           new Slot(slot.r, this.r) | ||||
|         ] | ||||
|       } // totally covered
 | ||||
|       else if (slot.l <= this.l && slot.r >= this.r) { | ||||
|         return [] | ||||
|       } // from left side
 | ||||
|       else if (slot.l <= this.l) { | ||||
|         if (slot.r == this.r) return [] | ||||
|         else return [new Slot(slot.r, this.r)] | ||||
|       } // from right side
 | ||||
|       else if (slot.r >= this.r) { | ||||
|         if (slot.l == this.l) return [] | ||||
|         else return [new Slot(this.l, slot.l)] | ||||
|       } | ||||
|     } else return [this] | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class TxSlot extends Slot { | ||||
|   tx: TxView | ||||
| 
 | ||||
|   constructor (l: number, r: number, tx: TxView) { | ||||
|     super(l, r) | ||||
|     this.tx = tx | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Row { | ||||
|   y: number | ||||
|   w: number | ||||
|   filled: TxSlot[] | ||||
|   slots: Slot[] | ||||
| 
 | ||||
| 
 | ||||
|   constructor (y: number, width: number) { | ||||
|     this.y = y | ||||
|     this.w = width | ||||
|     this.filled = [] | ||||
|     this.slots = [new Slot(0, this.w)] | ||||
|   } | ||||
| 
 | ||||
|   // insert a transaction w/ given width into row starting at position x
 | ||||
|   insert (x: number, w: number, tx: TxView): void { | ||||
|     const newSlot = new TxSlot(x, x + w, tx) | ||||
|     // insert into filled list
 | ||||
|     let index = this.filled.findIndex((slot) => { return slot.l >= newSlot.r }) | ||||
|     if (index < 0) index = this.filled.length | ||||
|     this.filled.splice(index || 0, 0, newSlot) | ||||
|     // subtract from overlapping slots
 | ||||
|     for (let i = 0; i < this.slots.length; i++) { | ||||
|       if (newSlot.intersects(this.slots[i])) { | ||||
|         const diff = this.slots[i].subtract(newSlot) | ||||
|         if (diff) { | ||||
|           this.slots.splice(i, 1, ...diff) | ||||
|           i += diff.length - 1 | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   remove (x: number, w: number): void { | ||||
|     const txIndex = this.filled.findIndex((slot) => { return slot.l == x }) | ||||
|     this.filled.splice(txIndex, 1) | ||||
| 
 | ||||
|     const newSlot = new Slot(x, x + w) | ||||
|     let slotIndex = this.slots.findIndex((slot) => { return slot.l >= newSlot.r }) | ||||
|     if (slotIndex < 0) slotIndex = this.slots.length | ||||
|     this.slots.splice(slotIndex || 0, 0, newSlot) | ||||
|     this.normalize() | ||||
|   } | ||||
| 
 | ||||
|   // merge any contiguous empty slots
 | ||||
|   private normalize (): void { | ||||
|     for (let i = 0; i < this.slots.length - 1; i++) { | ||||
|       if (this.slots[i].r == this.slots[i+1].l) { | ||||
|         this.slots[i].r = this.slots[i+1].r | ||||
|         this.slots[i].w += this.slots[i+1].w | ||||
|         this.slots.splice(i+1, 1) | ||||
|         i-- | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   txAt (x: number): TxView | void { | ||||
|     let i = 0 | ||||
|     while (i < this.filled.length && this.filled[i].l <= x) { | ||||
|       if (this.filled[i].l <= x && this.filled[i].r > x) return this.filled[i].tx | ||||
|       i++ | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   getSlotsBetween (left: number, right: number): TxSlot[] { | ||||
|     const range = new Slot(left, right) | ||||
|     return this.filled.filter(slot => { | ||||
|       return slot.intersects(range) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   slotAt (x: number): Slot | void { | ||||
|     let i = 0 | ||||
|     while (i < this.slots.length && this.slots[i].l <= x) { | ||||
|       if (this.slots[i].l <= x && this.slots[i].r > x) return this.slots[i] | ||||
|       i++ | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   getAvgFeerate (): number { | ||||
|     let count = 0 | ||||
|     let total = 0 | ||||
|     this.filled.forEach(slot => { | ||||
|       if (slot.tx) { | ||||
|         count += slot.w | ||||
|         total += (slot.tx.feerate * slot.w) | ||||
|       } | ||||
|     }) | ||||
|     return total / count | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class BlockLayout { | ||||
|   width: number; | ||||
|   height: number; | ||||
|   rows: Row[]; | ||||
|   txPositions: { [key: string]: Square } | ||||
|   txs: { [key: string]: TxView } | ||||
| 
 | ||||
|   constructor ({ width, height } : { width: number, height: number }) { | ||||
|     this.width = width | ||||
|     this.height = height | ||||
|     this.rows = [new Row(0, this.width)] | ||||
|     this.txPositions = {} | ||||
|     this.txs = {} | ||||
|   } | ||||
| 
 | ||||
|   getRow (position: Square): Row { | ||||
|     return this.rows[position.y] | ||||
|   } | ||||
| 
 | ||||
|   getTx (position: Square): TxView | void { | ||||
|     if (this.getRow(position)) { | ||||
|       return this.getRow(position).txAt(position.x) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   addRow (): void { | ||||
|     this.rows.push(new Row(this.rows.length, this.width)) | ||||
|   } | ||||
| 
 | ||||
|   remove (tx: TxView) { | ||||
|     const position = this.txPositions[tx.txid] | ||||
|     if (position) { | ||||
|       for (let y = position.y; y < position.y + position.s && y < this.rows.length; y++) { | ||||
|         this.rows[y].remove(position.x, position.s) | ||||
|       } | ||||
|     } | ||||
|     delete this.txPositions[tx.txid] | ||||
|     delete this.txs[tx.txid] | ||||
|   } | ||||
| 
 | ||||
|   insert (tx: TxView, width: number): Square { | ||||
|     const fit = this.fit(tx, width) | ||||
| 
 | ||||
|     // insert the tx into rows at that position
 | ||||
|     for (let y = fit.y; y < fit.y + width; y++) { | ||||
|       if (y >= this.rows.length) this.addRow() | ||||
|       this.rows[y].insert(fit.x, width, tx) | ||||
|     } | ||||
|     const position = { x: fit.x, y: fit.y, s: width } | ||||
|     this.txPositions[tx.txid] = position | ||||
|     this.txs[tx.txid] = tx | ||||
|     tx.applyGridPosition(position) | ||||
|     return position | ||||
|   } | ||||
| 
 | ||||
|   // Find the first slot large enough to hold a transaction of this size
 | ||||
|   fit (tx: TxView, width: number): Square { | ||||
|     let fit | ||||
|     for (let y = 0; y < this.rows.length && !fit; y++) { | ||||
|       fit = this.findFit(0, this.width, y, y, width) | ||||
|     } | ||||
|     // fall back to placing tx in a new row at the top of the layout
 | ||||
|     if (!fit) { | ||||
|       fit = { x: 0, y: this.rows.length } | ||||
|     } | ||||
|     return fit | ||||
|   } | ||||
| 
 | ||||
|   // recursively check rows to see if there's space for a tx (depth-first)
 | ||||
|   // left/right: initial column boundaries to check
 | ||||
|   // row: current row to check
 | ||||
|   // start: starting row
 | ||||
|   // size: size of space needed
 | ||||
|   findFit (left: number, right: number, row: number, start: number, size: number) : Square { | ||||
|     if ((row - start) >= size || row >= this.rows.length) { | ||||
|       return { x: left, y: start } | ||||
|     } | ||||
|     for (let i = 0; i < this.rows[row].slots.length; i++) { | ||||
|       const slot = this.rows[row].slots[i] | ||||
|       const l = Math.max(left, slot.l) | ||||
|       const r = Math.min(right, slot.r) | ||||
|       if (r - l >= size) { | ||||
|         const fit = this.findFit(l, r, row + 1, start, size) | ||||
|         if (fit) return fit | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // insert only if the tx fits into a fee-appropriate position
 | ||||
|   tryInsertByFee (tx: TxView, size: number): Square | void { | ||||
|     const fit = this.fit(tx, size) | ||||
| 
 | ||||
|     if (this.checkRowFees(fit.y, tx.feerate)) { | ||||
|       // insert the tx into rows at that position
 | ||||
|       for (let y = fit.y; y < fit.y + size; y++) { | ||||
|         if (y >= this.rows.length) this.addRow() | ||||
|         this.rows[y].insert(fit.x, size, tx) | ||||
|       } | ||||
|       const position = { x: fit.x, y: fit.y, s: size } | ||||
|       this.txPositions[tx.txid] = position | ||||
|       this.txs[tx.txid] = tx | ||||
|       tx.applyGridPosition(position) | ||||
|       return position | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Return the first slot with a lower feerate
 | ||||
|   getReplacementRoot (feerate: number, width: number): Square { | ||||
|     let slot | ||||
|     for (let row = 0; row <= this.rows.length; row++) { | ||||
|       if (this.rows[row].slots.length > 0) { | ||||
|         return { x: this.rows[row].slots[0].l, y: row } | ||||
|       } else { | ||||
|         slot = this.rows[row].filled.find(x => { | ||||
|           return x.tx.feerate < feerate | ||||
|         }) | ||||
|         if (slot) { | ||||
|           return { x: Math.min(slot.l, this.width - width), y: row } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return { x: 0, y: this.rows.length } | ||||
|   } | ||||
| 
 | ||||
|   // remove and return all transactions in a subtree of the layout
 | ||||
|   popTree (x: number, y: number, width: number) { | ||||
|     const selected: { [key: string]: TxView } = {} | ||||
|     let left = x | ||||
|     let right = x + width | ||||
|     let prevWidth = right - left | ||||
|     let prevFee = Infinity | ||||
|     // scan rows upwards within a channel bounded by 'left' and 'right'
 | ||||
|     for (let row = y; row < this.rows.length; row++) { | ||||
|       let rowMax = 0 | ||||
|       let slots = this.rows[row].getSlotsBetween(left, right) | ||||
|       // check each slot in this row overlapping the search channel
 | ||||
|       slots.forEach(slot => { | ||||
|         // select the associated transaction
 | ||||
|         selected[slot.tx.txid] = slot.tx | ||||
|         rowMax = Math.max(rowMax, slot.tx.feerate) | ||||
|         // widen the search channel to accommodate this slot if necessary
 | ||||
|         if (slot.w > prevWidth) { | ||||
|           left = slot.l | ||||
|           right = slot.r | ||||
|           // if this slot's tx has a higher feerate than the max in the previous row
 | ||||
|           // (i.e. it's out of position)
 | ||||
|           // select all txs overlapping the slot's full width in some rows *below*
 | ||||
|           // to free up space for this tx to sink down to its proper position
 | ||||
|           if (slot.tx.feerate > prevFee) { | ||||
|             let count = 0 | ||||
|             // keep scanning back down until we find a full row of higher-feerate txs
 | ||||
|             for (let echo = row - 1; echo >= 0 && count < slot.w; echo--) { | ||||
|               let echoSlots = this.rows[echo].getSlotsBetween(slot.l, slot.r) | ||||
|               count = 0 | ||||
|               echoSlots.forEach(echoSlot => { | ||||
|                 selected[echoSlot.tx.txid] = echoSlot.tx | ||||
|                 if (echoSlot.tx.feerate >= slot.tx.feerate) { | ||||
|                   count += echoSlot.w | ||||
|                 } | ||||
|               }) | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
|       prevWidth = right - left | ||||
|       prevFee = rowMax | ||||
|     } | ||||
| 
 | ||||
|     const txList = Object.values(selected) | ||||
| 
 | ||||
|     txList.forEach(tx => { | ||||
|       this.remove(tx) | ||||
|     }) | ||||
|     return txList | ||||
|   } | ||||
| 
 | ||||
|   // Check if this row has high enough avg fees
 | ||||
|   // for a tx with this feerate to make sense here
 | ||||
|   checkRowFees (row: number, targetFee: number): boolean { | ||||
|     // first row is always fine
 | ||||
|     if (row == 0 || !this.rows[row]) return true | ||||
|     return (this.rows[row].getAvgFeerate() > (targetFee * 0.9)) | ||||
|   } | ||||
| 
 | ||||
|   // drop any free-floating transactions down into empty spaces
 | ||||
|   applyGravity (): void { | ||||
|     Object.entries(this.txPositions).sort(([keyA, posA], [keyB, posB]) => { | ||||
|       return posA.y - posB.y || posA.x - posB.x | ||||
|     }).forEach(([txid, position]) => { | ||||
|       // see how far this transaction can fall
 | ||||
|       let dropTo = position.y | ||||
|       while (dropTo > 0 && !this.rows[dropTo - 1].getSlotsBetween(position.x, position.x + position.s).length) { | ||||
|         dropTo--; | ||||
|       } | ||||
|       // if it can fall at all
 | ||||
|       if (dropTo < position.y) { | ||||
|         // remove and reinsert in the row we found
 | ||||
|         const tx = this.txs[txid] | ||||
|         this.remove(tx) | ||||
|         this.insert(tx, position.s) | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| @ -1,6 +1,9 @@ | ||||
| <div class="mempool-block-overview"> | ||||
|   <canvas class="block-overview" [style.width]="cssWidth + 'px'" [style.height]="cssHeight + 'px'" #blockCanvas></canvas> | ||||
|   <div class="loader-wrapper" [class.hidden]="!(isLoading$ | async)"> | ||||
|     <div class="spinner-border ml-3 loading" role="status"></div> | ||||
|   </div> | ||||
| </div> | ||||
| <app-block-overview-graph | ||||
|   #blockGraph | ||||
|   [isLoading]="isLoading$ | async" | ||||
|   [resolution]="75" | ||||
|   [blockLimit]="stateService.blockVSize" | ||||
|   [orientation]="'left'" | ||||
|   [flip]="true" | ||||
|   (txClickEvent)="onTxClick($event)" | ||||
| ></app-block-overview-graph> | ||||
|  | ||||
| @ -1,40 +1,25 @@ | ||||
| import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, OnInit, | ||||
|   OnDestroy, OnChanges, ChangeDetectionStrategy, NgZone, AfterViewInit } from '@angular/core'; | ||||
| import { Component, ComponentRef, ViewChild, HostListener, Input, Output, EventEmitter, | ||||
|   OnDestroy, OnChanges, ChangeDetectionStrategy, AfterViewInit } from '@angular/core'; | ||||
| import { StateService } from 'src/app/services/state.service'; | ||||
| import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface'; | ||||
| import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component'; | ||||
| import { Subscription, BehaviorSubject, merge, of } from 'rxjs'; | ||||
| import { switchMap, filter } from 'rxjs/operators'; | ||||
| import { WebsocketService } from 'src/app/services/websocket.service'; | ||||
| import { FastVertexArray } from './fast-vertex-array'; | ||||
| import BlockScene from './block-scene'; | ||||
| import TxSprite from './tx-sprite'; | ||||
| import TxView from './tx-view'; | ||||
| import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { Router } from '@angular/router'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-mempool-block-overview', | ||||
|   templateUrl: './mempool-block-overview.component.html', | ||||
|   styleUrls: ['./mempool-block-overview.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { | ||||
| export class MempoolBlockOverviewComponent implements OnDestroy, OnChanges, AfterViewInit { | ||||
|   @Input() index: number; | ||||
|   @Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>(); | ||||
| 
 | ||||
|   @ViewChild('blockCanvas') | ||||
|   canvas: ElementRef<HTMLCanvasElement>; | ||||
|   @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; | ||||
| 
 | ||||
|   gl: WebGLRenderingContext; | ||||
|   animationFrameRequest: number; | ||||
|   displayWidth: number; | ||||
|   displayHeight: number; | ||||
|   cssWidth: number; | ||||
|   cssHeight: number; | ||||
|   shaderProgram: WebGLProgram; | ||||
|   vertexArray: FastVertexArray; | ||||
|   running: boolean; | ||||
|   scene: BlockScene; | ||||
|   hoverTx: TxView | void; | ||||
|   selectedTx: TxView | void; | ||||
|   lastBlockHeight: number; | ||||
|   blockIndex: number; | ||||
|   isLoading$ = new BehaviorSubject<boolean>(true); | ||||
| @ -45,12 +30,10 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang | ||||
|   constructor( | ||||
|     public stateService: StateService, | ||||
|     private websocketService: WebsocketService, | ||||
|     readonly ngZone: NgZone, | ||||
|   ) { | ||||
|     this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); | ||||
|   } | ||||
|     private router: Router, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|   ngAfterViewInit(): void { | ||||
|     this.blockSub = merge( | ||||
|         of(true), | ||||
|         this.stateService.connectionState$.pipe(filter((state) => state === 2)) | ||||
| @ -64,18 +47,11 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   ngAfterViewInit(): void { | ||||
|     this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false); | ||||
|     this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false); | ||||
|     this.gl = this.canvas.nativeElement.getContext('webgl'); | ||||
|     this.initCanvas(); | ||||
| 
 | ||||
|     this.resizeCanvas(); | ||||
|   } | ||||
| 
 | ||||
|   ngOnChanges(changes): void { | ||||
|     if (changes.index) { | ||||
|       this.clearBlock(changes.index.currentValue > changes.index.previousValue ? 'right' : 'left'); | ||||
|       if (this.blockGraph) { | ||||
|         this.blockGraph.clear(changes.index.currentValue > changes.index.previousValue ? 'right' : 'left'); | ||||
|       } | ||||
|       this.isLoading$.next(true); | ||||
|       this.websocketService.startTrackMempoolBlock(changes.index.currentValue); | ||||
|     } | ||||
| @ -87,26 +63,13 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang | ||||
|     this.websocketService.stopTrackMempoolBlock(); | ||||
|   } | ||||
| 
 | ||||
|   clearBlock(direction): void { | ||||
|     if (this.scene) { | ||||
|       this.scene.exit(direction); | ||||
|     } | ||||
|     this.hoverTx = null; | ||||
|     this.selectedTx = null; | ||||
|     this.txPreviewEvent.emit(null); | ||||
|   } | ||||
| 
 | ||||
|   replaceBlock(transactionsStripped: TransactionStripped[]): void { | ||||
|     if (!this.scene) { | ||||
|       this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: 75, | ||||
|         blockLimit: this.stateService.blockVSize, vertexArray: this.vertexArray }); | ||||
|     } | ||||
|     const blockMined = (this.stateService.latestBlockHeight > this.lastBlockHeight); | ||||
|     if (this.blockIndex !== this.index) { | ||||
|       const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right'; | ||||
|       this.scene.enter(transactionsStripped, direction); | ||||
|       this.blockGraph.enter(transactionsStripped, direction); | ||||
|     } else { | ||||
|       this.scene.replace(transactionsStripped, blockMined ? 'right' : 'left'); | ||||
|       this.blockGraph.replace(transactionsStripped, blockMined ? 'right' : 'left'); | ||||
|     } | ||||
| 
 | ||||
|     this.lastBlockHeight = this.stateService.latestBlockHeight; | ||||
| @ -115,20 +78,13 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang | ||||
|   } | ||||
| 
 | ||||
|   updateBlock(delta: MempoolBlockDelta): void { | ||||
|     if (!this.scene) { | ||||
|       this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: 75, | ||||
|         blockLimit: this.stateService.blockVSize, vertexArray: this.vertexArray }); | ||||
|     } | ||||
|     const blockMined = (this.stateService.latestBlockHeight > this.lastBlockHeight); | ||||
| 
 | ||||
|     if (this.blockIndex !== this.index) { | ||||
|       const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right'; | ||||
|       this.scene.exit(direction); | ||||
|       this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: 75, | ||||
|         blockLimit: this.stateService.blockVSize, vertexArray: this.vertexArray }); | ||||
|       this.scene.enter(delta.added, direction); | ||||
|       this.blockGraph.replace(delta.added, direction); | ||||
|     } else { | ||||
|       this.scene.update(delta.added, delta.removed, blockMined ? 'right' : 'left', blockMined); | ||||
|       this.blockGraph.update(delta.added, delta.removed, blockMined ? 'right' : 'left', blockMined); | ||||
|     } | ||||
| 
 | ||||
|     this.lastBlockHeight = this.stateService.latestBlockHeight; | ||||
| @ -136,279 +92,8 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang | ||||
|     this.isLoading$.next(false); | ||||
|   } | ||||
| 
 | ||||
|   initCanvas(): void { | ||||
|     this.gl.clearColor(0.0, 0.0, 0.0, 0.0); | ||||
|     this.gl.clear(this.gl.COLOR_BUFFER_BIT); | ||||
| 
 | ||||
|     const shaderSet = [ | ||||
|       { | ||||
|         type: this.gl.VERTEX_SHADER, | ||||
|         src: vertShaderSrc | ||||
|       }, | ||||
|       { | ||||
|         type: this.gl.FRAGMENT_SHADER, | ||||
|         src: fragShaderSrc | ||||
|       } | ||||
|     ]; | ||||
| 
 | ||||
|     this.shaderProgram = this.buildShaderProgram(shaderSet); | ||||
| 
 | ||||
|     this.gl.useProgram(this.shaderProgram); | ||||
| 
 | ||||
|     // Set up alpha blending
 | ||||
|     this.gl.enable(this.gl.BLEND); | ||||
|     this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); | ||||
| 
 | ||||
|     const glBuffer = this.gl.createBuffer(); | ||||
|     this.gl.bindBuffer(this.gl.ARRAY_BUFFER, glBuffer); | ||||
| 
 | ||||
|     /* SET UP SHADER ATTRIBUTES */ | ||||
|     Object.keys(attribs).forEach((key, i) => { | ||||
|       attribs[key].pointer = this.gl.getAttribLocation(this.shaderProgram, key); | ||||
|       this.gl.enableVertexAttribArray(attribs[key].pointer); | ||||
|     }); | ||||
| 
 | ||||
|     this.start(); | ||||
|   } | ||||
| 
 | ||||
|   handleContextLost(event): void { | ||||
|     event.preventDefault(); | ||||
|     cancelAnimationFrame(this.animationFrameRequest); | ||||
|     this.animationFrameRequest = null; | ||||
|     this.running = false; | ||||
|   } | ||||
| 
 | ||||
|   handleContextRestored(event): void { | ||||
|     this.initCanvas(); | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('window:resize', ['$event']) | ||||
|   resizeCanvas(): void { | ||||
|     this.cssWidth = this.canvas.nativeElement.parentElement.clientWidth; | ||||
|     this.cssHeight = this.canvas.nativeElement.parentElement.clientHeight; | ||||
|     this.displayWidth = window.devicePixelRatio * this.cssWidth; | ||||
|     this.displayHeight = window.devicePixelRatio * this.cssHeight; | ||||
|     this.canvas.nativeElement.width = this.displayWidth; | ||||
|     this.canvas.nativeElement.height = this.displayHeight; | ||||
|     if (this.gl) { | ||||
|       this.gl.viewport(0, 0, this.displayWidth, this.displayHeight); | ||||
|     } | ||||
|     if (this.scene) { | ||||
|       this.scene.resize({ width: this.displayWidth, height: this.displayHeight }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   compileShader(src, type): WebGLShader { | ||||
|     const shader = this.gl.createShader(type); | ||||
| 
 | ||||
|     this.gl.shaderSource(shader, src); | ||||
|     this.gl.compileShader(shader); | ||||
| 
 | ||||
|     if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { | ||||
|       console.log(`Error compiling ${type === this.gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader:`); | ||||
|       console.log(this.gl.getShaderInfoLog(shader)); | ||||
|     } | ||||
|     return shader; | ||||
|   } | ||||
| 
 | ||||
|   buildShaderProgram(shaderInfo): WebGLProgram { | ||||
|     const program = this.gl.createProgram(); | ||||
| 
 | ||||
|     shaderInfo.forEach((desc) => { | ||||
|       const shader = this.compileShader(desc.src, desc.type); | ||||
|       if (shader) { | ||||
|         this.gl.attachShader(program, shader); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     this.gl.linkProgram(program); | ||||
| 
 | ||||
|     if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) { | ||||
|       console.log('Error linking shader program:'); | ||||
|       console.log(this.gl.getProgramInfoLog(program)); | ||||
|     } | ||||
| 
 | ||||
|     return program; | ||||
|   } | ||||
| 
 | ||||
|   start(): void { | ||||
|     this.running = true; | ||||
|     this.ngZone.runOutsideAngular(() => this.run()); | ||||
|   } | ||||
| 
 | ||||
|   run(now?: DOMHighResTimeStamp): void { | ||||
|     if (!now) { | ||||
|       now = performance.now(); | ||||
|     } | ||||
| 
 | ||||
|     /* SET UP SHADER UNIFORMS */ | ||||
|     // screen dimensions
 | ||||
|     this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight); | ||||
|     // frame timestamp
 | ||||
|     this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, 'now'), now); | ||||
| 
 | ||||
|     /* SET UP SHADER ATTRIBUTES */ | ||||
|     Object.keys(attribs).forEach((key, i) => { | ||||
|       this.gl.vertexAttribPointer(attribs[key].pointer, | ||||
|       attribs[key].count,  // number of primitives in this attribute
 | ||||
|       this.gl[attribs[key].type],  // type of primitive in this attribute (e.g. gl.FLOAT)
 | ||||
|       false, // never normalised
 | ||||
|       stride,   // distance between values of the same attribute
 | ||||
|       attribs[key].offset);  // offset of the first value
 | ||||
|     }); | ||||
| 
 | ||||
|     const pointArray = this.vertexArray.getVertexData(); | ||||
| 
 | ||||
|     if (pointArray.length) { | ||||
|       this.gl.bufferData(this.gl.ARRAY_BUFFER, pointArray, this.gl.DYNAMIC_DRAW); | ||||
|       this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize); | ||||
|     } | ||||
| 
 | ||||
|     /* LOOP */ | ||||
|     if (this.running) { | ||||
|       if (this.animationFrameRequest) { | ||||
|         cancelAnimationFrame(this.animationFrameRequest); | ||||
|         this.animationFrameRequest = null; | ||||
|       } | ||||
|       this.animationFrameRequest = requestAnimationFrame(() => this.run()); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('click', ['$event']) | ||||
|   onClick(event) { | ||||
|     this.setPreviewTx(event.offsetX, event.offsetY, true); | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('pointermove', ['$event']) | ||||
|   onPointerMove(event) { | ||||
|     this.setPreviewTx(event.offsetX, event.offsetY, false); | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('pointerleave', ['$event']) | ||||
|   onPointerLeave(event) { | ||||
|     this.setPreviewTx(-1, -1, false); | ||||
|   } | ||||
| 
 | ||||
|   setPreviewTx(cssX: number, cssY: number, clicked: boolean = false) { | ||||
|     const x = cssX * window.devicePixelRatio; | ||||
|     const y = cssY * window.devicePixelRatio; | ||||
|     if (this.scene && (!this.selectedTx || clicked)) { | ||||
|       const selected = this.scene.getTxAt({ x, y }); | ||||
|       const currentPreview = this.selectedTx || this.hoverTx; | ||||
| 
 | ||||
|       if (selected !== currentPreview) { | ||||
|         if (currentPreview) { | ||||
|           currentPreview.setHover(false); | ||||
|         } | ||||
|         if (selected) { | ||||
|           selected.setHover(true); | ||||
|           this.txPreviewEvent.emit({ | ||||
|             txid: selected.txid, | ||||
|             fee: selected.fee, | ||||
|             vsize: selected.vsize, | ||||
|             value: selected.value | ||||
|           }); | ||||
|           if (clicked) { | ||||
|             this.selectedTx = selected; | ||||
|           } else { | ||||
|             this.hoverTx = selected; | ||||
|           } | ||||
|         } else { | ||||
|           if (clicked) { | ||||
|             this.selectedTx = null; | ||||
|           } | ||||
|           this.hoverTx = null; | ||||
|           this.txPreviewEvent.emit(null); | ||||
|         } | ||||
|       } else if (clicked) { | ||||
|         if (selected === this.selectedTx) { | ||||
|           this.hoverTx = this.selectedTx; | ||||
|           this.selectedTx = null; | ||||
|         } else { | ||||
|           this.selectedTx = selected; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   onTxClick(event: TransactionStripped): void { | ||||
|     const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); | ||||
|     this.router.navigate([url]); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // WebGL shader attributes
 | ||||
| const attribs = { | ||||
|   offset: { type: 'FLOAT', count: 2, pointer: null, offset: 0 }, | ||||
|   posX: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   posY: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   posR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   colR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   colG: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   colB: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }, | ||||
|   colA: { type: 'FLOAT', count: 4, pointer: null, offset: 0 } | ||||
| }; | ||||
| // Calculate the number of bytes per vertex based on specified attributes
 | ||||
| const stride = Object.values(attribs).reduce((total, attrib) => { | ||||
|   return total + (attrib.count * 4); | ||||
| }, 0); | ||||
| // Calculate vertex attribute offsets
 | ||||
| for (let i = 0, offset = 0; i < Object.keys(attribs).length; i++) { | ||||
|   const attrib = Object.values(attribs)[i]; | ||||
|   attrib.offset = offset; | ||||
|   offset += (attrib.count * 4); | ||||
| } | ||||
| 
 | ||||
| const vertShaderSrc = ` | ||||
| varying lowp vec4 vColor; | ||||
| 
 | ||||
| // each attribute contains [x: startValue, y: endValue, z: startTime, w: rate]
 | ||||
| // shader interpolates between start and end values at the given rate, from the given time
 | ||||
| 
 | ||||
| attribute vec2 offset; | ||||
| attribute vec4 posX; | ||||
| attribute vec4 posY; | ||||
| attribute vec4 posR; | ||||
| attribute vec4 colR; | ||||
| attribute vec4 colG; | ||||
| attribute vec4 colB; | ||||
| attribute vec4 colA; | ||||
| 
 | ||||
| uniform vec2 screenSize; | ||||
| uniform float now; | ||||
| 
 | ||||
| float smootherstep(float x) { | ||||
|   x = clamp(x, 0.0, 1.0); | ||||
|   float ix = 1.0 - x; | ||||
|   x = x * x; | ||||
|   return x / (x + ix * ix); | ||||
| } | ||||
| 
 | ||||
| float interpolateAttribute(vec4 attr) { | ||||
|   float d = (now - attr.z) * attr.w; | ||||
|   float delta = smootherstep(d); | ||||
|   return mix(attr.x, attr.y, delta); | ||||
| } | ||||
| 
 | ||||
| void main() { | ||||
|   vec4 screenTransform = vec4(2.0 / screenSize.x, 2.0 / screenSize.y, -1.0, -1.0); | ||||
|   // vec4 screenTransform = vec4(1.0 / screenSize.x, 1.0 / screenSize.y, -0.5, -0.5);
 | ||||
| 
 | ||||
|   float radius = interpolateAttribute(posR); | ||||
|   vec2 position = vec2(interpolateAttribute(posX), interpolateAttribute(posY)) + (radius * offset); | ||||
| 
 | ||||
|   gl_Position = vec4(position * screenTransform.xy + screenTransform.zw, 1.0, 1.0); | ||||
| 
 | ||||
|   float red = interpolateAttribute(colR); | ||||
|   float green = interpolateAttribute(colG); | ||||
|   float blue = interpolateAttribute(colB); | ||||
|   float alpha = interpolateAttribute(colA); | ||||
| 
 | ||||
|   vColor = vec4(red, green, blue, alpha); | ||||
| } | ||||
| `;
 | ||||
| 
 | ||||
| const fragShaderSrc = ` | ||||
| varying lowp vec4 vColor; | ||||
| 
 | ||||
| void main() { | ||||
|   gl_FragColor = vColor; | ||||
|   // premultiply alpha
 | ||||
|   gl_FragColor.rgb *= gl_FragColor.a; | ||||
| } | ||||
| `;
 | ||||
|  | ||||
| @ -10,62 +10,33 @@ | ||||
|   <div class="box"> | ||||
|     <div class="row"> | ||||
|       <div class="col-md"> | ||||
|         <table class="table table-borderless table-striped table-fixed"> | ||||
|         <table class="table table-borderless table-striped"> | ||||
|           <tbody> | ||||
|             <ng-container *ngIf="!previewTx"> | ||||
|               <tr> | ||||
|                 <td i18n="mempool-block.median-fee">Median fee</td> | ||||
|                 <td>~{{ mempoolBlock.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="mempoolBlock.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="mempool-block.fee-span">Fee span</td> | ||||
|                 <td><span class="yellow-color">{{ mempoolBlock.feeRange[0] | number:'1.0-0' }} - {{ mempoolBlock.feeRange[mempoolBlock.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|                 <td><app-amount [satoshis]="mempoolBlock.totalFees" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> <span class="fiat"><app-fiat [value]="mempoolBlock.totalFees" digitsInfo="1.0-0"></app-fiat></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="mempool-block.transactions">Transactions</td> | ||||
|                 <td>{{ mempoolBlock.nTx }}</td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="mempool-block.size">Size</td> | ||||
|                 <td> | ||||
|                   <div class="progress"> | ||||
|                     <div class="progress-bar progress-mempool {{ (network$ | async) }}" role="progressbar" [ngStyle]="{'width': (mempoolBlock.blockVSize / stateService.blockVSize) * 100 + '%' }"></div> | ||||
|                     <div class="progress-text" [innerHTML]="mempoolBlock.blockSize | bytes: 2"></div> | ||||
|                   </div> | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </ng-container> | ||||
| 
 | ||||
|             <ng-container *ngIf="previewTx"> | ||||
|               <tr> | ||||
|                 <td i18n="shared.transaction">Transaction</td> | ||||
|                 <td> | ||||
|                   <a [routerLink]="['/tx/' | relativeUrl, previewTx.txid]">{{ previewTx.txid | shortenString : 16}}</a> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td class="td-width" i18n="transaction.value|Transaction value">Value</td> | ||||
|                 <td><app-amount [satoshis]="previewTx.value"></app-amount></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td> | ||||
|                 <td>{{ previewTx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> <span class="fiat"><app-fiat [value]="previewTx.fee"></app-fiat></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td> | ||||
|                 <td> | ||||
|                   {{ (previewTx.fee / previewTx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td> | ||||
|                 <td [innerHTML]="'‎' + (previewTx.vsize | vbytes: 2)"></td> | ||||
|               </tr> | ||||
|               </ng-container> | ||||
|             <tr> | ||||
|               <td i18n="mempool-block.median-fee">Median fee</td> | ||||
|               <td>~{{ mempoolBlock.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="mempoolBlock.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td i18n="mempool-block.fee-span">Fee span</td> | ||||
|               <td><span class="yellow-color">{{ mempoolBlock.feeRange[0] | number:'1.0-0' }} - {{ mempoolBlock.feeRange[mempoolBlock.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|               <td><app-amount [satoshis]="mempoolBlock.totalFees" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> <span class="fiat"><app-fiat [value]="mempoolBlock.totalFees" digitsInfo="1.0-0"></app-fiat></span></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td i18n="mempool-block.transactions">Transactions</td> | ||||
|               <td>{{ mempoolBlock.nTx }}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td i18n="mempool-block.size">Size</td> | ||||
|               <td> | ||||
|                 <div class="progress"> | ||||
|                   <div class="progress-bar progress-mempool {{ (network$ | async) }}" role="progressbar" [ngStyle]="{'width': (mempoolBlock.blockVSize / stateService.blockVSize) * 100 + '%' }"></div> | ||||
|                   <div class="progress-text" [innerHTML]="mempoolBlock.blockSize | bytes: 2"></div> | ||||
|                 </div> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|         <app-fee-distribution-graph *ngIf="webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph> | ||||
|  | ||||
| @ -81,12 +81,12 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { | ||||
|   } | ||||
| 
 | ||||
|   setTxPreview(event: TransactionStripped | void): void { | ||||
|     this.previewTx = event | ||||
|     this.previewTx = event; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function detectWebGL () { | ||||
|   const canvas = document.createElement("canvas"); | ||||
|   const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); | ||||
|   return (gl && gl instanceof WebGLRenderingContext) | ||||
| function detectWebGL() { | ||||
|   const canvas = document.createElement('canvas'); | ||||
|   const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); | ||||
|   return (gl && gl instanceof WebGLRenderingContext); | ||||
| } | ||||
|  | ||||
| @ -128,6 +128,13 @@ export interface BlockExtended extends Block { | ||||
|   extras?: BlockExtension; | ||||
| } | ||||
| 
 | ||||
| export interface TransactionStripped { | ||||
|   txid: string; | ||||
|   fee: number; | ||||
|   vsize: number; | ||||
|   value: number; | ||||
| } | ||||
| 
 | ||||
| export interface RewardStats { | ||||
|   startBlock: number; | ||||
|   endBlock: number; | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { HttpClient, HttpParams } from '@angular/common/http'; | ||||
| import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolsStats, PoolStat, BlockExtended, RewardStats } from '../interfaces/node-api.interface'; | ||||
| import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, | ||||
|          PoolsStats, PoolStat, BlockExtended, TransactionStripped, RewardStats } from '../interfaces/node-api.interface'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { StateService } from './state.service'; | ||||
| import { WebsocketResponse } from '../interfaces/websocket.interface'; | ||||
| @ -158,6 +159,10 @@ export class ApiService { | ||||
|     return this.httpClient.get<BlockExtended>(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash); | ||||
|   } | ||||
| 
 | ||||
|   getStrippedBlockTransactions$(hash: string): Observable<TransactionStripped[]> { | ||||
|     return this.httpClient.get<TransactionStripped[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash + '/summary'); | ||||
|   } | ||||
| 
 | ||||
|   getHistoricalHashrate$(interval: string | undefined): Observable<any> { | ||||
|     return this.httpClient.get<any[]>( | ||||
|         this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/hashrate` + | ||||
|  | ||||
| @ -45,6 +45,8 @@ import { StartComponent } from '../components/start/start.component'; | ||||
| import { TransactionComponent } from '../components/transaction/transaction.component'; | ||||
| import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; | ||||
| import { BlockComponent } from '../components/block/block.component'; | ||||
| import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; | ||||
| import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; | ||||
| import { AddressComponent } from '../components/address/address.component'; | ||||
| import { SearchFormComponent } from '../components/search-form/search-form.component'; | ||||
| import { AddressLabelsComponent } from '../components/address-labels/address-labels.component'; | ||||
| @ -110,6 +112,8 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | ||||
|     StartComponent, | ||||
|     TransactionComponent, | ||||
|     BlockComponent, | ||||
|     BlockOverviewGraphComponent, | ||||
|     BlockOverviewTooltipComponent, | ||||
|     TransactionsListComponent, | ||||
|     AddressComponent, | ||||
|     SearchFormComponent, | ||||
| @ -203,6 +207,8 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | ||||
|     StartComponent, | ||||
|     TransactionComponent, | ||||
|     BlockComponent, | ||||
|     BlockOverviewGraphComponent, | ||||
|     BlockOverviewTooltipComponent, | ||||
|     TransactionsListComponent, | ||||
|     AddressComponent, | ||||
|     SearchFormComponent, | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user