Extract canvas/webgl code to separate component
This commit is contained in:
		
							parent
							
								
									c5bcf76353
								
							
						
					
					
						commit
						225decd286
					
				| @ -0,0 +1,6 @@ | ||||
| <div class="block-overview-graph"> | ||||
|   <canvas class="block-overview-canvas" #blockCanvas></canvas> | ||||
|   <div class="loader-wrapper" [class.hidden]="!isLoading"> | ||||
|     <div class="spinner-border ml-3 loading" role="status"></div> | ||||
|   </div> | ||||
| </div> | ||||
| @ -1,4 +1,4 @@ | ||||
| .mempool-block-overview { | ||||
| .block-overview-graph { | ||||
|   position: relative; | ||||
|   width: 100%; | ||||
|   padding-bottom: 100%; | ||||
| @ -8,12 +8,15 @@ | ||||
|   align-items: center; | ||||
| } | ||||
| 
 | ||||
| .block-overview { | ||||
| 
 | ||||
| .block-overview-canvas { | ||||
|   position: absolute; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   top: 0; | ||||
|   bottom: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| @ -27,6 +30,7 @@ | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   transition: opacity 500ms 500ms; | ||||
|   pointer-events: none; | ||||
| 
 | ||||
|   &.hidden { | ||||
|     opacity: 0; | ||||
| @ -0,0 +1,360 @@ | ||||
| import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit } 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'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-overview-graph', | ||||
|   templateUrl: './block-overview-graph.component.html', | ||||
|   styleUrls: ['./block-overview-graph.component.scss'], | ||||
| }) | ||||
| export class BlockOverviewGraphComponent implements AfterViewInit { | ||||
|   @Input() isLoading: boolean; | ||||
|   @Input() resolution: number; | ||||
|   @Input() blockLimit: number; | ||||
|   @Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>(); | ||||
| 
 | ||||
|   @ViewChild('blockCanvas') | ||||
|   canvas: ElementRef<HTMLCanvasElement>; | ||||
| 
 | ||||
|   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; | ||||
| 
 | ||||
|   constructor( | ||||
|     readonly ngZone: NgZone, | ||||
|   ) { | ||||
|     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(); | ||||
|   } | ||||
| 
 | ||||
|   clear(direction): void { | ||||
|     this.exit(direction); | ||||
|     this.hoverTx = null; | ||||
|     this.selectedTx = null; | ||||
|     this.txPreviewEvent.emit(null); | ||||
|   } | ||||
| 
 | ||||
|   enter(transactions: TransactionStripped[], direction: string): void { | ||||
|     if (this.scene) { | ||||
|       this.scene.enter(transactions, direction); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   exit(direction: string): void { | ||||
|     if (this.scene) { | ||||
|       this.scene.exit(direction); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   replace(transactions: TransactionStripped[], direction: string): void { | ||||
|     if (this.scene) { | ||||
|       this.scene.replace(transactions, direction); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { | ||||
|     if (this.scene) { | ||||
|       this.scene.update(add, remove, direction, resetLayout); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   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 }); | ||||
|     } else { | ||||
|       this.scene = this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, | ||||
|         blockLimit: this.blockLimit, vertexArray: this.vertexArray }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   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; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // 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; | ||||
| } | ||||
| `;
 | ||||
							
								
								
									
										724
									
								
								frontend/src/app/components/block-overview-graph/block-scene.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										724
									
								
								frontend/src/app/components/block-overview-graph/block-scene.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,724 @@ | ||||
| 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(feeRateDescending).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(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, 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 (const id of ids) { | ||||
|       this.updateTx(this.txs[id], 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 { | ||||
|     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; | ||||
| } | ||||
| @ -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,7 @@ | ||||
| <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" | ||||
|   (txPreviewEvent)="onTxPreview($event)"> | ||||
| </app-block-overview-graph> | ||||
|  | ||||
| @ -1,40 +1,23 @@ | ||||
| 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'; | ||||
| 
 | ||||
| @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); | ||||
| @ -44,13 +27,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 websocketService: WebsocketService | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|   ngAfterViewInit(): void { | ||||
|     this.blockSub = merge( | ||||
|         of(true), | ||||
|         this.stateService.connectionState$.pipe(filter((state) => state === 2)) | ||||
| @ -64,18 +44,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 +60,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 +75,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 +89,7 @@ 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; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   onTxPreview(event: TransactionStripped | void): void { | ||||
|     this.txPreviewEvent.emit(event); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // 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; | ||||
| } | ||||
| `;
 | ||||
|  | ||||
| @ -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); | ||||
| } | ||||
|  | ||||
| @ -45,6 +45,7 @@ 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 { 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 +111,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | ||||
|     StartComponent, | ||||
|     TransactionComponent, | ||||
|     BlockComponent, | ||||
|     BlockOverviewGraphComponent, | ||||
|     TransactionsListComponent, | ||||
|     AddressComponent, | ||||
|     SearchFormComponent, | ||||
| @ -203,6 +205,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | ||||
|     StartComponent, | ||||
|     TransactionComponent, | ||||
|     BlockComponent, | ||||
|     BlockOverviewGraphComponent, | ||||
|     TransactionsListComponent, | ||||
|     AddressComponent, | ||||
|     SearchFormComponent, | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user