From 225decd286111cb3cb838c03b2edd3b6fba547c8 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 14 Jun 2022 00:33:48 +0000 Subject: [PATCH] Extract canvas/webgl code to separate component --- .../block-overview-graph.component.html | 6 + .../block-overview-graph.component.scss} | 8 +- .../block-overview-graph.component.ts | 360 +++++++++ .../block-overview-graph/block-scene.ts | 724 ++++++++++++++++++ .../fast-vertex-array.ts | 0 .../sprite-types.ts | 0 .../tx-sprite.ts | 0 .../tx-view.ts | 0 .../mempool-block-overview/block-scene.ts | 684 ----------------- .../mempool-block-overview.component.html | 13 +- .../mempool-block-overview.component.ts | 353 +-------- .../mempool-block/mempool-block.component.ts | 10 +- frontend/src/app/shared/shared.module.ts | 3 + 13 files changed, 1128 insertions(+), 1033 deletions(-) create mode 100644 frontend/src/app/components/block-overview-graph/block-overview-graph.component.html rename frontend/src/app/components/{mempool-block-overview/mempool-block-overview.component.scss => block-overview-graph/block-overview-graph.component.scss} (82%) create mode 100644 frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts create mode 100644 frontend/src/app/components/block-overview-graph/block-scene.ts rename frontend/src/app/components/{mempool-block-overview => block-overview-graph}/fast-vertex-array.ts (100%) rename frontend/src/app/components/{mempool-block-overview => block-overview-graph}/sprite-types.ts (100%) rename frontend/src/app/components/{mempool-block-overview => block-overview-graph}/tx-sprite.ts (100%) rename frontend/src/app/components/{mempool-block-overview => block-overview-graph}/tx-view.ts (100%) delete mode 100644 frontend/src/app/components/mempool-block-overview/block-scene.ts diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html new file mode 100644 index 000000000..017eeab99 --- /dev/null +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html @@ -0,0 +1,6 @@ +
+ +
+
+
+
diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.scss b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss similarity index 82% rename from frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.scss rename to frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss index 8c3c271d1..cbb95ec0e 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.scss +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss @@ -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; diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts new file mode 100644 index 000000000..cbb0225ff --- /dev/null +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -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(); + + @ViewChild('blockCanvas') + canvas: ElementRef; + + 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; +} +`; diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts new file mode 100644 index 000000000..b71c1ab6c --- /dev/null +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -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; +} diff --git a/frontend/src/app/components/mempool-block-overview/fast-vertex-array.ts b/frontend/src/app/components/block-overview-graph/fast-vertex-array.ts similarity index 100% rename from frontend/src/app/components/mempool-block-overview/fast-vertex-array.ts rename to frontend/src/app/components/block-overview-graph/fast-vertex-array.ts diff --git a/frontend/src/app/components/mempool-block-overview/sprite-types.ts b/frontend/src/app/components/block-overview-graph/sprite-types.ts similarity index 100% rename from frontend/src/app/components/mempool-block-overview/sprite-types.ts rename to frontend/src/app/components/block-overview-graph/sprite-types.ts diff --git a/frontend/src/app/components/mempool-block-overview/tx-sprite.ts b/frontend/src/app/components/block-overview-graph/tx-sprite.ts similarity index 100% rename from frontend/src/app/components/mempool-block-overview/tx-sprite.ts rename to frontend/src/app/components/block-overview-graph/tx-sprite.ts diff --git a/frontend/src/app/components/mempool-block-overview/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts similarity index 100% rename from frontend/src/app/components/mempool-block-overview/tx-view.ts rename to frontend/src/app/components/block-overview-graph/tx-view.ts diff --git a/frontend/src/app/components/mempool-block-overview/block-scene.ts b/frontend/src/app/components/mempool-block-overview/block-scene.ts deleted file mode 100644 index 38ca35eda..000000000 --- a/frontend/src/app/components/mempool-block-overview/block-scene.ts +++ /dev/null @@ -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) - } - }) - } -} diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html index 58f09548f..c9696f222 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html @@ -1,6 +1,7 @@ -
- -
-
-
-
+ + diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index ab9ffb3b5..aed949af3 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -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(); - @ViewChild('blockCanvas') - canvas: ElementRef; + @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(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; -} -`; diff --git a/frontend/src/app/components/mempool-block/mempool-block.component.ts b/frontend/src/app/components/mempool-block/mempool-block.component.ts index e20c0b67d..75e171f2b 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.ts +++ b/frontend/src/app/components/mempool-block/mempool-block.component.ts @@ -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); } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 8b280e9c8..5886be5d3 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -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,