From 225decd286111cb3cb838c03b2edd3b6fba547c8 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 14 Jun 2022 00:33:48 +0000 Subject: [PATCH 1/8] 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, From 7f4c6352ba509002a5c62d4d9112a6f93346de08 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 14 Jun 2022 16:39:37 +0000 Subject: [PATCH 2/8] Add visualization to mined blocks --- .../block-overview-graph.component.ts | 11 +- .../block-overview-graph/block-scene.ts | 109 +++++-- .../app/components/block/block.component.html | 275 +++++++++++------- .../app/components/block/block.component.scss | 7 + .../app/components/block/block.component.ts | 84 +++++- .../mempool-block-overview.component.html | 6 +- .../src/app/interfaces/node-api.interface.ts | 7 + frontend/src/app/services/api.service.ts | 7 +- 8 files changed, 366 insertions(+), 140 deletions(-) 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 index cbb0225ff..c596691ad 100644 --- 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 @@ -15,6 +15,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit { @Input() isLoading: boolean; @Input() resolution: number; @Input() blockLimit: number; + @Input() orientation = 'left'; + @Input() flip = true; @Output() txPreviewEvent = new EventEmitter(); @ViewChild('blockCanvas') @@ -67,9 +69,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit { } } - replace(transactions: TransactionStripped[], direction: string): void { + replace(transactions: TransactionStripped[], direction: string, sort: boolean = true): void { if (this.scene) { - this.scene.replace(transactions, direction); + this.scene.replace(transactions || [], direction, sort); } } @@ -139,8 +141,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit { 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 }); + this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, + blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray }); + this.start(); } } diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index b71c1ab6c..ffae2ed6a 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -7,6 +7,8 @@ export default class BlockScene { scene: { count: number, offset: { x: number, y: number}}; vertexArray: FastVertexArray; txs: { [key: string]: TxView }; + orientation: string; + flip: boolean; width: number; height: number; gridWidth: number; @@ -19,10 +21,11 @@ export default class BlockScene { layout: BlockLayout; dirty: boolean; - constructor({ width, height, resolution, blockLimit, vertexArray }: - { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray } + constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: + { width: number, height: number, resolution: number, blockLimit: number, + orientation: string, flip: boolean, vertexArray: FastVertexArray } ) { - this.init({ width, height, resolution, blockLimit, vertexArray }); + this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }); } destroy(): void { @@ -61,7 +64,7 @@ export default class BlockScene { } // Reset layout and replace with new set of transactions - replace(txs: TransactionStripped[], direction: string = 'left'): void { + replace(txs: TransactionStripped[], direction: string = 'left', sort: boolean = true): void { const startTime = performance.now(); const nextIds = {}; const remove = []; @@ -90,9 +93,15 @@ export default class BlockScene { this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); - Object.values(this.txs).sort(feeRateDescending).forEach(tx => { - this.place(tx); - }); + if (sort) { + Object.values(this.txs).sort(feeRateDescending).forEach(tx => { + this.place(tx); + }); + } else { + txs.forEach(tx => { + this.place(this.txs[tx.txid]); + }); + } this.updateAll(startTime, direction); } @@ -143,9 +152,12 @@ export default class BlockScene { } } - private init({ width, height, resolution, blockLimit, vertexArray }: - { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray } + private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: + { width: number, height: number, resolution: number, blockLimit: number, + orientation: string, flip: boolean, vertexArray: FastVertexArray } ): void { + this.orientation = orientation; + this.flip = flip; this.vertexArray = vertexArray; this.scene = { @@ -188,8 +200,8 @@ export default class BlockScene { tx.update({ display: { position: { - x: tx.screenPosition.x + (direction === 'right' ? -this.width : this.width) * 1.4, - y: tx.screenPosition.y, + x: tx.screenPosition.x + (direction === 'right' ? -this.width : (direction === 'left' ? this.width : 0)) * 1.4, + y: tx.screenPosition.y + (direction === 'up' ? -this.height : (direction === 'down' ? this.height : 0)) * 1.4, s: tx.screenPosition.s }, color: txColor, @@ -237,8 +249,8 @@ export default class BlockScene { tx.update({ display: { position: { - x: tx.screenPosition.x + (direction === 'right' ? this.width : -this.width) * 1.4, - y: this.txs[id].screenPosition.y, + x: tx.screenPosition.x + (direction === 'right' ? this.width : (direction === 'left' ? -this.width : 0)) * 1.4, + y: tx.screenPosition.y + (direction === 'up' ? this.height : (direction === 'down' ? -this.height : 0)) * 1.4, } }, duration: 1000, @@ -264,18 +276,42 @@ export default class BlockScene { 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 + // The grid is laid out notionally left-to-right, bottom-to-top, + // so we rotate and/or flip the y axis to match the target configuration. + // + // e.g. for flip = true, orientation = 'left': // // grid screen - // ________ ________ ________ - // | | | b| | a| - // | | rotate | | flip | c | - // | c | --> | c | --> | | - // |a______b| |_______a| |_______b| + // ________ ________ ________ + // | | | | | a| + // | | flip | | rotate | c | + // | c | --> | c | --> | | + // |a______b| |b______a| |_______b| + + let x = (this.gridSize * position.x) + (slotSize / 2); + let y = (this.gridSize * position.y) + (slotSize / 2); + let t; + if (this.flip) { + x = this.width - x; + } + switch (this.orientation) { + case 'left': + t = x; + x = this.width - y; + y = t; + break; + case 'right': + t = x; + x = y; + y = t; + break; + case 'bottom': + y = this.height - y; + break; + } return { - x: this.width + (this.unitPadding * 2) - (this.gridSize * position.y) - slotSize, - y: this.height - ((this.gridSize * position.x) + (slotSize - this.unitPadding)), + x: x + this.unitPadding - (slotSize / 2), + y: y + this.unitPadding - (slotSize / 2), s: squareSize }; } else { @@ -284,11 +320,32 @@ export default class BlockScene { } 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) + let x = position.x; + let y = this.height - position.y; + let t; + + switch (this.orientation) { + case 'left': + t = x; + x = y; + y = this.width - t; + break; + case 'right': + t = x; + x = y; + y = t; + break; + case 'bottom': + y = this.height - y; + break; + } + if (this.flip) { + x = this.width - x; + } + return { + x: Math.floor(x / this.gridSize), + y: Math.floor(y / this.gridSize) }; - return grid; } // calculates and returns the size of the tx in multiples of the grid size diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index 00fc18f2a..fdf5caf4e 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -40,10 +40,11 @@
- -
-
+ +
+
+
@@ -68,73 +69,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Weight
Median fee~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB
Total fees + + + + + +   +
Subsidy + fees: + + + + +
Total fees
Subsidy + fees:
Miner + + {{ block.extras.pool.name }} + + + + {{ block.extras.pool.name }} + +
+
+
- - - + + - + + + + + + + + + + - - - - - + - - - + + + + + + + + - - - - - - - - - - - - - - -
Median fee~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB
Total fees - - - - - -   -
Subsidy + fees: - - - - -
Total fees
Subsidy + fees:
Miner - - {{ block.extras.pool.name }} - - - - {{ block.extras.pool.name }} - -
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Median fee~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB
Total fees + + + + + +   +
Subsidy + fees: + + + + +
Total fees
Subsidy + fees:
Miner + + {{ block.extras.pool.name }} + + + + {{ block.extras.pool.name }} + +
+ + + + + + + + + + + + + + + +
+
+
+
- +
+

@@ -223,63 +342,17 @@
+
-
- - - - -
-
-
- - - - - - - - - - - - - - - -
-
-
- - - - - - - - - - - - - - - -
-
-
-
-
-
Error loading data. diff --git a/frontend/src/app/components/block/block.component.scss b/frontend/src/app/components/block/block.component.scss index 067d250e2..f047cbcfa 100644 --- a/frontend/src/app/components/block/block.component.scss +++ b/frontend/src/app/components/block/block.component.scss @@ -148,3 +148,10 @@ h1 { } } } + +.chart-container{ + margin: 20px auto; + @media (min-width: 768px) { + margin: auto; + } +} diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index bd70e8628..4ffacabaa 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -2,15 +2,16 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/co import { Location } from '@angular/common'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; -import { switchMap, tap, debounceTime, catchError, map } from 'rxjs/operators'; +import { switchMap, tap, debounceTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators'; import { Transaction, Vout } from '../../interfaces/electrs.interface'; import { Observable, of, Subscription } from 'rxjs'; import { StateService } from '../../services/state.service'; import { SeoService } from 'src/app/services/seo.service'; import { WebsocketService } from 'src/app/services/websocket.service'; import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; -import { BlockExtended } from 'src/app/interfaces/node-api.interface'; +import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface'; import { ApiService } from 'src/app/services/api.service'; +import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component'; @Component({ selector: 'app-block', @@ -21,6 +22,7 @@ export class BlockComponent implements OnInit, OnDestroy { network = ''; block: BlockExtended; blockHeight: number; + lastBlockHeight: number; nextBlockHeight: number; blockHash: string; isLoadingBlock = true; @@ -28,6 +30,10 @@ export class BlockComponent implements OnInit, OnDestroy { latestBlocks: BlockExtended[] = []; transactions: Transaction[]; isLoadingTransactions = true; + strippedTransactions: TransactionStripped[]; + overviewTransitionDirection: string; + isLoadingOverview = true; + isAwaitingOverview = true; error: any; blockSubsidy: number; fees: number; @@ -39,13 +45,18 @@ export class BlockComponent implements OnInit, OnDestroy { showPreviousBlocklink = true; showNextBlocklink = true; transactionsError: any = null; + overviewError: any = null; + webGlEnabled = true; - subscription: Subscription; + transactionSubscription: Subscription; + overviewSubscription: Subscription; keyNavigationSubscription: Subscription; blocksSubscription: Subscription; networkChangedSubscription: Subscription; queryParamsSubscription: Subscription; + @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; + constructor( private route: ActivatedRoute, private location: Location, @@ -56,7 +67,9 @@ export class BlockComponent implements OnInit, OnDestroy { private websocketService: WebsocketService, private relativeUrlPipe: RelativeUrlPipe, private apiService: ApiService - ) { } + ) { + this.webGlEnabled = detectWebGL(); + } ngOnInit() { this.websocketService.want(['blocks', 'mempool-blocks']); @@ -85,7 +98,7 @@ export class BlockComponent implements OnInit, OnDestroy { } }); - this.subscription = this.route.paramMap.pipe( + const block$ = this.route.paramMap.pipe( switchMap((params: ParamMap) => { const blockHash: string = params.get('id') || ''; this.block = undefined; @@ -141,6 +154,8 @@ export class BlockComponent implements OnInit, OnDestroy { tap((block: BlockExtended) => { this.block = block; this.blockHeight = block.height; + const direction = (this.lastBlockHeight < this.blockHeight) ? 'right' : 'left'; + this.lastBlockHeight = this.blockHeight; this.nextBlockHeight = block.height + 1; this.setNextAndPreviousBlockLink(); @@ -154,8 +169,17 @@ export class BlockComponent implements OnInit, OnDestroy { this.isLoadingTransactions = true; this.transactions = null; this.transactionsError = null; + this.isLoadingOverview = true; + this.isAwaitingOverview = true; + this.overviewError = true; + if (this.blockGraph) { + this.blockGraph.exit(direction); + } }), debounceTime(300), + shareReplay(1) + ); + this.transactionSubscription = block$.pipe( switchMap((block) => this.electrsApiService.getBlockTransactions$(block.id) .pipe( catchError((err) => { @@ -170,10 +194,51 @@ export class BlockComponent implements OnInit, OnDestroy { } this.transactions = transactions; this.isLoadingTransactions = false; + + if (!this.isAwaitingOverview && this.blockGraph && this.strippedTransactions && this.overviewTransitionDirection) { + this.isLoadingOverview = false; + this.blockGraph.replace(this.strippedTransactions, this.overviewTransitionDirection, false); + } }, (error) => { this.error = error; this.isLoadingBlock = false; + this.isLoadingOverview = false; + }); + + this.overviewSubscription = block$.pipe( + startWith(null), + pairwise(), + switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id) + .pipe( + catchError((err) => { + this.overviewError = err; + return of([]); + }), + switchMap((transactions) => { + console.log('overview loaded: ', prevBlock && prevBlock.height, block.height); + if (prevBlock) { + return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' }); + } else { + return of({ transactions, direction: 'down' }); + } + }) + ) + ), + ) + .subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { + this.isAwaitingOverview = false; + this.strippedTransactions = transactions; + this.overviewTransitionDirection = direction; + if (!this.isLoadingTransactions && this.blockGraph) { + this.isLoadingOverview = false; + this.blockGraph.replace(this.strippedTransactions, this.overviewTransitionDirection, false); + } + }, + (error) => { + this.error = error; + this.isLoadingOverview = false; + this.isAwaitingOverview = false; }); this.networkChangedSubscription = this.stateService.networkChanged$ @@ -203,7 +268,8 @@ export class BlockComponent implements OnInit, OnDestroy { ngOnDestroy() { this.stateService.markBlock$.next({}); - this.subscription.unsubscribe(); + this.transactionSubscription.unsubscribe(); + this.overviewSubscription.unsubscribe(); this.keyNavigationSubscription.unsubscribe(); this.blocksSubscription.unsubscribe(); this.networkChangedSubscription.unsubscribe(); @@ -303,3 +369,9 @@ export class BlockComponent implements OnInit, OnDestroy { } } } + +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/components/mempool-block-overview/mempool-block-overview.component.html b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html index c9696f222..2b6ff37a5 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 @@ -3,5 +3,7 @@ [isLoading]="isLoading$ | async" [resolution]="75" [blockLimit]="stateService.blockVSize" - (txPreviewEvent)="onTxPreview($event)"> - + [orientation]="'left'" + [flip]="true" + (txPreviewEvent)="onTxPreview($event)" +> diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index d1f9932d7..6d3e7c0d8 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -128,6 +128,13 @@ export interface BlockExtended extends Block { extras?: BlockExtension; } +export interface TransactionStripped { + txid: string; + fee: number; + vsize: number; + value: number; +} + export interface RewardStats { startBlock: number; endBlock: number; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 134877f72..8202fbb49 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolsStats, PoolStat, BlockExtended, RewardStats } from '../interfaces/node-api.interface'; +import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, + PoolsStats, PoolStat, BlockExtended, TransactionStripped, RewardStats } from '../interfaces/node-api.interface'; import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; @@ -158,6 +159,10 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash); } + getStrippedBlockTransactions$(hash: string): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash + '/summary'); + } + getHistoricalHashrate$(interval: string | undefined): Observable { return this.httpClient.get( this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/hashrate` + From 300f5375c89dd40fc4607498681c4830b9b73552 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 14 Jun 2022 23:08:18 +0000 Subject: [PATCH 3/8] Optimize block visualization rendering --- .../block-overview-graph.component.ts | 98 +++++++++++++------ .../block-overview-graph/block-scene.ts | 45 +++++---- .../block-overview-graph/fast-vertex-array.ts | 8 ++ .../block-overview-graph/tx-view.ts | 10 +- .../app/components/block/block.component.ts | 1 - 5 files changed, 108 insertions(+), 54 deletions(-) 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 index c596691ad..953ed5715 100644 --- 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 @@ -1,4 +1,4 @@ -import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit } from '@angular/core'; +import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core'; import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface'; import { WebsocketService } from 'src/app/services/websocket.service'; import { FastVertexArray } from './fast-vertex-array'; @@ -11,7 +11,7 @@ import TxView from './tx-view'; templateUrl: './block-overview-graph.component.html', styleUrls: ['./block-overview-graph.component.scss'], }) -export class BlockOverviewGraphComponent implements AfterViewInit { +export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { @Input() isLoading: boolean; @Input() resolution: number; @Input() blockLimit: number; @@ -24,6 +24,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit { gl: WebGLRenderingContext; animationFrameRequest: number; + animationHeartBeat: number; displayWidth: number; displayHeight: number; cssWidth: number; @@ -50,34 +51,46 @@ export class BlockOverviewGraphComponent implements AfterViewInit { this.resizeCanvas(); } + ngOnDestroy(): void { + if (this.animationFrameRequest) { + cancelAnimationFrame(this.animationFrameRequest); + clearTimeout(this.animationHeartBeat); + } + } + clear(direction): void { this.exit(direction); this.hoverTx = null; this.selectedTx = null; this.txPreviewEvent.emit(null); + this.start(); } enter(transactions: TransactionStripped[], direction: string): void { if (this.scene) { this.scene.enter(transactions, direction); + this.start(); } } exit(direction: string): void { if (this.scene) { this.scene.exit(direction); + this.start(); } } replace(transactions: TransactionStripped[], direction: string, sort: boolean = true): void { if (this.scene) { this.scene.replace(transactions || [], direction, sort); + this.start(); } } update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { if (this.scene) { this.scene.update(add, remove, direction, resetLayout); + this.start(); } } @@ -140,6 +153,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit { } if (this.scene) { this.scene.resize({ width: this.displayWidth, height: this.displayHeight }); + this.start(); } else { this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray }); @@ -182,44 +196,64 @@ export class BlockOverviewGraphComponent implements AfterViewInit { start(): void { this.running = true; - this.ngZone.runOutsideAngular(() => this.run()); + this.ngZone.runOutsideAngular(() => this.doRun()); + } + + doRun(): void { + if (this.animationFrameRequest) { + cancelAnimationFrame(this.animationFrameRequest); + } + this.animationFrameRequest = requestAnimationFrame(() => this.run()); } run(now?: DOMHighResTimeStamp): void { if (!now) { now = performance.now(); } + // skip re-render if there's no change to the scene + if (this.scene) { + /* SET UP SHADER UNIFORMS */ + // screen dimensions + this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight); + // frame timestamp + this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, 'now'), now); - /* SET UP SHADER UNIFORMS */ - // screen dimensions - this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight); - // frame timestamp - this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, 'now'), now); + if (this.vertexArray.dirty) { + /* SET UP SHADER ATTRIBUTES */ + Object.keys(attribs).forEach((key, i) => { + this.gl.vertexAttribPointer(attribs[key].pointer, + attribs[key].count, // number of primitives in this attribute + this.gl[attribs[key].type], // type of primitive in this attribute (e.g. gl.FLOAT) + false, // never normalised + stride, // distance between values of the same attribute + attribs[key].offset); // offset of the first value + }); - /* 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(); - 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); + if (pointArray.length) { + this.gl.bufferData(this.gl.ARRAY_BUFFER, pointArray, this.gl.DYNAMIC_DRAW); + this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize); + } + this.vertexArray.dirty = false; + } else { + const pointArray = this.vertexArray.getVertexData(); + if (pointArray.length) { + this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize); + } + } } /* LOOP */ - if (this.running) { - if (this.animationFrameRequest) { - cancelAnimationFrame(this.animationFrameRequest); - this.animationFrameRequest = null; + if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) { + this.doRun(); + } else { + if (this.animationHeartBeat) { + clearTimeout(this.animationHeartBeat); } - this.animationFrameRequest = requestAnimationFrame(() => this.run()); + this.animationHeartBeat = window.setTimeout(() => { + this.start(); + }, 1000); } } @@ -246,11 +280,15 @@ export class BlockOverviewGraphComponent implements AfterViewInit { const currentPreview = this.selectedTx || this.hoverTx; if (selected !== currentPreview) { - if (currentPreview) { - currentPreview.setHover(false); + if (currentPreview && this.scene) { + this.scene.setHover(currentPreview, false); + this.start(); } if (selected) { - selected.setHover(true); + if (selected && this.scene) { + this.scene.setHover(selected, true); + this.start(); + } this.txPreviewEvent.emit({ txid: selected.txid, fee: selected.fee, diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index ffae2ed6a..fc5bfff8e 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -1,7 +1,7 @@ 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'; +import { Position, Square, ViewUpdateParams } from './sprite-types'; export default class BlockScene { scene: { count: number, offset: { x: number, y: number}}; @@ -19,6 +19,7 @@ export default class BlockScene { unitWidth: number; initialised: boolean; layout: BlockLayout; + animateUntil = 0; dirty: boolean; constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: @@ -41,7 +42,7 @@ export default class BlockScene { this.dirty = true; if (this.initialised && this.scene) { - this.updateAll(performance.now()); + this.updateAll(performance.now(), 50); } } @@ -103,7 +104,7 @@ export default class BlockScene { }); } - this.updateAll(startTime, direction); + this.updateAll(startTime, 200, direction); } update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { @@ -139,7 +140,7 @@ export default class BlockScene { this.layout.applyGravity(); } - this.updateAll(startTime, direction); + this.updateAll(startTime, 100, direction); } // return the tx at this screen position, if any @@ -152,6 +153,10 @@ export default class BlockScene { } } + setHover(tx: TxView, value: boolean): void { + this.animateUntil = Math.max(this.animateUntil, tx.setHover(value)); + } + private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: { width: number, height: number, resolution: number, blockLimit: number, orientation: string, flip: boolean, vertexArray: FastVertexArray } @@ -169,7 +174,7 @@ export default class BlockScene { }; // Set the scale of the visualization (with a 5% margin) - this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.05, 2); + this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.02, 2); this.gridWidth = resolution; this.gridHeight = resolution; this.resize({ width, height }); @@ -181,23 +186,21 @@ export default class BlockScene { 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 applyTxUpdate(tx: TxView, update: ViewUpdateParams): void { + this.animateUntil = Math.max(this.animateUntil, tx.update(update)); } - private updateTx(tx: TxView, startTime: number, direction: string = 'left'): void { + private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left'): void { if (tx.dirty || this.dirty) { this.saveGridToScreenPosition(tx); - this.setTxOnScreen(tx, startTime, direction); + this.setTxOnScreen(tx, startTime, delay, direction); } } - private setTxOnScreen(tx: TxView, startTime: number, direction: string = 'left'): void { + private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left'): void { if (!tx.initialised) { const txColor = tx.getColor(); - tx.update({ + this.applyTxUpdate(tx, { display: { position: { x: tx.screenPosition.x + (direction === 'right' ? -this.width : (direction === 'left' ? this.width : 0)) * 1.4, @@ -209,35 +212,35 @@ export default class BlockScene { start: startTime, delay: 0, }); - tx.update({ + this.applyTxUpdate(tx, { display: { position: tx.screenPosition, color: txColor }, duration: 1000, start: startTime, - delay: 50, + delay, }); } else { - tx.update({ + this.applyTxUpdate(tx, { display: { position: tx.screenPosition }, duration: 1000, minDuration: 500, start: startTime, - delay: 50, + delay, adjust: true }); } } - private updateAll(startTime: number, direction: string = 'left'): void { + private updateAll(startTime: number, delay: number = 50, direction: string = 'left'): void { this.scene.count = 0; const ids = this.getTxList(); startTime = startTime || performance.now(); for (const id of ids) { - this.updateTx(this.txs[id], startTime, direction); + this.updateTx(this.txs[id], startTime, delay, direction); } this.dirty = false; } @@ -246,7 +249,7 @@ export default class BlockScene { const tx = this.txs[id]; if (tx) { this.layout.remove(tx); - tx.update({ + this.applyTxUpdate(tx, { display: { position: { x: tx.screenPosition.x + (direction === 'right' ? this.width : (direction === 'left' ? -this.width : 0)) * 1.4, @@ -319,7 +322,7 @@ export default class BlockScene { } } - screenToGrid(position: Position): Position { + private screenToGrid(position: Position): Position { let x = position.x; let y = this.height - position.y; let t; diff --git a/frontend/src/app/components/block-overview-graph/fast-vertex-array.ts b/frontend/src/app/components/block-overview-graph/fast-vertex-array.ts index 0581fb391..bc0900238 100644 --- a/frontend/src/app/components/block-overview-graph/fast-vertex-array.ts +++ b/frontend/src/app/components/block-overview-graph/fast-vertex-array.ts @@ -18,6 +18,7 @@ export class FastVertexArray { data: Float32Array; freeSlots: number[]; lastSlot: number; + dirty = false; constructor(length, stride) { this.length = length; @@ -27,6 +28,7 @@ export class FastVertexArray { this.data = new Float32Array(this.length * this.stride); this.freeSlots = []; this.lastSlot = 0; + this.dirty = true; } insert(sprite: TxSprite): number { @@ -44,6 +46,7 @@ export class FastVertexArray { } this.sprites[position] = sprite; return position; + this.dirty = true; } remove(index: number): void { @@ -54,14 +57,17 @@ export class FastVertexArray { if (this.length > 2048 && this.count < (this.length * 0.4)) { this.compact(); } + this.dirty = true; } setData(index: number, dataChunk: number[]): void { this.data.set(dataChunk, (index * this.stride)); + this.dirty = true; } clearData(index: number): void { this.data.fill(0, (index * this.stride), ((index + 1) * this.stride)); + this.dirty = true; } getData(index: number): Float32Array { @@ -73,6 +79,7 @@ export class FastVertexArray { const newData = new Float32Array(this.length * this.stride); newData.set(this.data); this.data = newData; + this.dirty = true; } compact(): void { @@ -97,6 +104,7 @@ export class FastVertexArray { this.freeSlots = []; this.lastSlot = i; } + this.dirty = true; } getVertexData(): Float32Array { diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index cb98bef72..5093f480b 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -82,8 +82,10 @@ export default class TxView implements TransactionStripped { delay: additional milliseconds to wait before starting jitter: if set, adds a random amount to the delay, adjust: if true, modify an in-progress transition instead of replacing it + + returns minimum transition end time */ - update(params: ViewUpdateParams): void { + update(params: ViewUpdateParams): number { if (params.jitter) { params.delay += (Math.random() * params.jitter); } @@ -96,6 +98,7 @@ export default class TxView implements TransactionStripped { ); // apply any pending hover event if (this.hover) { + params.duration = Math.max(params.duration, hoverTransitionTime); this.sprite.update({ ...this.hoverColor, duration: hoverTransitionTime, @@ -109,10 +112,12 @@ export default class TxView implements TransactionStripped { ); } this.dirty = false; + return (params.start || performance.now()) + (params.delay || 0) + (params.duration || 0); } // Temporarily override the tx color - setHover(hoverOn: boolean, color: Color | void = defaultHoverColor): void { + // returns minimum transition end time + setHover(hoverOn: boolean, color: Color | void = defaultHoverColor): number { if (hoverOn) { this.hover = true; this.hoverColor = color; @@ -131,6 +136,7 @@ export default class TxView implements TransactionStripped { } } this.dirty = false; + return performance.now() + hoverTransitionTime; } getColor(): Color { diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 4ffacabaa..6394a624e 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -216,7 +216,6 @@ export class BlockComponent implements OnInit, OnDestroy { return of([]); }), switchMap((transactions) => { - console.log('overview loaded: ', prevBlock && prevBlock.height, block.height); if (prevBlock) { return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' }); } else { From 2d529bd5816553484b778a35f2b553ae89d142b8 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 15 Jun 2022 01:40:05 +0000 Subject: [PATCH 4/8] Tooltip-style tx previews in block overview --- .../block-overview-graph.component.html | 8 +- .../block-overview-graph.component.scss | 4 + .../block-overview-graph.component.ts | 55 ++++++++++--- .../block-overview-tooltip.component.html | 37 +++++++++ .../block-overview-tooltip.component.scss | 18 +++++ .../block-overview-tooltip.component.ts | 53 ++++++++++++ .../app/components/block/block.component.html | 1 + .../app/components/block/block.component.ts | 5 ++ .../mempool-block-overview.component.html | 2 +- .../mempool-block-overview.component.ts | 10 ++- .../mempool-block.component.html | 81 ++++++------------- frontend/src/app/shared/shared.module.ts | 3 + 12 files changed, 204 insertions(+), 73 deletions(-) create mode 100644 frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html create mode 100644 frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss create mode 100644 frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.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 index 017eeab99..517eab653 100644 --- 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 @@ -1,6 +1,12 @@
- +
+ +
diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss index cbb95ec0e..05b9b340a 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss @@ -18,6 +18,10 @@ width: 100%; height: 100%; overflow: hidden; + + &.clickable { + cursor: pointer; + } } .loader-wrapper { 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 index 953ed5715..a458ebd5f 100644 --- 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 @@ -5,6 +5,7 @@ import { FastVertexArray } from './fast-vertex-array'; import BlockScene from './block-scene'; import TxSprite from './tx-sprite'; import TxView from './tx-view'; +import { Position } from './sprite-types'; @Component({ selector: 'app-block-overview-graph', @@ -17,7 +18,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { @Input() blockLimit: number; @Input() orientation = 'left'; @Input() flip = true; - @Output() txPreviewEvent = new EventEmitter(); + @Output() txClickEvent = new EventEmitter(); @ViewChild('blockCanvas') canvas: ElementRef; @@ -35,9 +36,11 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { scene: BlockScene; hoverTx: TxView | void; selectedTx: TxView | void; + tooltipPosition: Position; constructor( readonly ngZone: NgZone, + readonly elRef: ElementRef, ) { this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); } @@ -62,7 +65,6 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { this.exit(direction); this.hoverTx = null; this.selectedTx = null; - this.txPreviewEvent.emit(null); this.start(); } @@ -257,25 +259,50 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { } } - @HostListener('click', ['$event']) + @HostListener('document:click', ['$event']) + clickAway(event) { + if (!this.elRef.nativeElement.contains(event.target)) { + const currentPreview = this.selectedTx || this.hoverTx; + if (currentPreview && this.scene) { + this.scene.setHover(currentPreview, false); + this.start(); + } + this.hoverTx = null; + this.selectedTx = null; + } + } + + @HostListener('pointerup', ['$event']) onClick(event) { - this.setPreviewTx(event.offsetX, event.offsetY, true); + if (event.target === this.canvas.nativeElement && event.pointerType === 'touch') { + this.setPreviewTx(event.offsetX, event.offsetY, true); + } else if (event.target === this.canvas.nativeElement) { + this.onTxClick(event.offsetX, event.offsetY); + } } @HostListener('pointermove', ['$event']) onPointerMove(event) { - this.setPreviewTx(event.offsetX, event.offsetY, false); + if (event.target === this.canvas.nativeElement) { + this.setPreviewTx(event.offsetX, event.offsetY, false); + } } @HostListener('pointerleave', ['$event']) onPointerLeave(event) { - this.setPreviewTx(-1, -1, false); + if (event.pointerType !== 'touch') { + this.setPreviewTx(-1, -1, true); + } } setPreviewTx(cssX: number, cssY: number, clicked: boolean = false) { const x = cssX * window.devicePixelRatio; const y = cssY * window.devicePixelRatio; if (this.scene && (!this.selectedTx || clicked)) { + this.tooltipPosition = { + x: cssX, + y: cssY + }; const selected = this.scene.getTxAt({ x, y }); const currentPreview = this.selectedTx || this.hoverTx; @@ -289,12 +316,6 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { this.scene.setHover(selected, true); this.start(); } - this.txPreviewEvent.emit({ - txid: selected.txid, - fee: selected.fee, - vsize: selected.vsize, - value: selected.value - }); if (clicked) { this.selectedTx = selected; } else { @@ -305,7 +326,6 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { this.selectedTx = null; } this.hoverTx = null; - this.txPreviewEvent.emit(null); } } else if (clicked) { if (selected === this.selectedTx) { @@ -317,6 +337,15 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { } } } + + onTxClick(cssX: number, cssY: number) { + const x = cssX * window.devicePixelRatio; + const y = cssY * window.devicePixelRatio; + const selected = this.scene.getTxAt({ x, y }); + if (selected && selected.txid) { + this.txClickEvent.emit(selected); + } + } } // WebGL shader attributes diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html new file mode 100644 index 000000000..431e2d1d9 --- /dev/null +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html @@ -0,0 +1,37 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Transaction + {{ txid | shortenString : 16}} +
Value
Fee{{ fee | number }} sat  
Fee rate + {{ feeRate | feeRounding }} sat/vB +
Virtual size
+
diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss new file mode 100644 index 000000000..440885750 --- /dev/null +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss @@ -0,0 +1,18 @@ +.block-overview-tooltip { + position: absolute; + background: rgba(#11131f, 0.95); + border-radius: 4px; + box-shadow: 1px 1px 10px rgba(0,0,0,0.5); + color: #b1b1b1; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 15px; + text-align: left; + width: 320px; + pointer-events: none; + + &.clickable { + pointer-events: all; + } +} diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts new file mode 100644 index 000000000..603b5fcdb --- /dev/null +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts @@ -0,0 +1,53 @@ +import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core'; +import { TransactionStripped } from 'src/app/interfaces/websocket.interface'; +import { Position } from 'src/app/components/block-overview-graph/sprite-types.js'; + +@Component({ + selector: 'app-block-overview-tooltip', + templateUrl: './block-overview-tooltip.component.html', + styleUrls: ['./block-overview-tooltip.component.scss'], +}) +export class BlockOverviewTooltipComponent implements OnChanges { + @Input() tx: TransactionStripped | void; + @Input() cursorPosition: Position; + @Input() clickable: boolean; + + txid = ''; + fee = 0; + value = 0; + vsize = 1; + feeRate = 0; + + tooltipPosition: Position = { x: 0, y: 0 }; + + @ViewChild('tooltip') tooltipElement: ElementRef; + + constructor() {} + + ngOnChanges(changes): void { + if (changes.cursorPosition && changes.cursorPosition.currentValue) { + let x = changes.cursorPosition.currentValue.x + 10; + let y = changes.cursorPosition.currentValue.y + 10; + if (this.tooltipElement) { + const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect(); + const parentBounds = this.tooltipElement.nativeElement.offsetParent.getBoundingClientRect(); + if ((parentBounds.left + x + elementBounds.width) > parentBounds.right) { + x = Math.max(0, parentBounds.width - elementBounds.width - 10); + } + if (y + elementBounds.height > parentBounds.height) { + y = y - elementBounds.height - 20; + } + } + this.tooltipPosition = { x, y }; + } + + if (changes.tx) { + const tx = changes.tx.currentValue || {}; + this.txid = tx.txid || ''; + this.fee = tx.fee || 0; + this.value = tx.value || 0; + this.vsize = tx.vsize || 1; + this.feeRate = this.fee / this.vsize; + } + } +} diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index fdf5caf4e..1216ecc30 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -249,6 +249,7 @@ [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" + (txClickEvent)="onTxClick($event)" >
diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 6394a624e..39c4042fb 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -367,6 +367,11 @@ export class BlockComponent implements OnInit, OnDestroy { } } } + + onTxClick(event: TransactionStripped): void { + const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); + this.router.navigate([url]); + } } function detectWebGL() { 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 2b6ff37a5..304b2a7f9 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 @@ -5,5 +5,5 @@ [blockLimit]="stateService.blockVSize" [orientation]="'left'" [flip]="true" - (txPreviewEvent)="onTxPreview($event)" + (txClickEvent)="onTxClick($event)" > 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 aed949af3..bd78b13a9 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 @@ -6,6 +6,8 @@ import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-g import { Subscription, BehaviorSubject, merge, of } from 'rxjs'; import { switchMap, filter } from 'rxjs/operators'; import { WebsocketService } from 'src/app/services/websocket.service'; +import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; +import { Router } from '@angular/router'; @Component({ selector: 'app-mempool-block-overview', @@ -27,7 +29,8 @@ export class MempoolBlockOverviewComponent implements OnDestroy, OnChanges, Afte constructor( public stateService: StateService, - private websocketService: WebsocketService + private websocketService: WebsocketService, + private router: Router, ) { } ngAfterViewInit(): void { @@ -89,7 +92,8 @@ export class MempoolBlockOverviewComponent implements OnDestroy, OnChanges, Afte this.isLoading$.next(false); } - onTxPreview(event: TransactionStripped | void): void { - this.txPreviewEvent.emit(event); + onTxClick(event: TransactionStripped): void { + const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); + this.router.navigate([url]); } } diff --git a/frontend/src/app/components/mempool-block/mempool-block.component.html b/frontend/src/app/components/mempool-block/mempool-block.component.html index da1e91f1b..3626e6ff5 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.html +++ b/frontend/src/app/components/mempool-block/mempool-block.component.html @@ -10,62 +10,33 @@
- +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + +
Median fee~{{ mempoolBlock.medianFee | number:'1.0-0' }} sat/vB
Fee span{{ mempoolBlock.feeRange[0] | number:'1.0-0' }} - {{ mempoolBlock.feeRange[mempoolBlock.feeRange.length - 1] | number:'1.0-0' }} sat/vB
Total fees
Transactions{{ mempoolBlock.nTx }}
Size -
-
-
-
-
Transaction - {{ previewTx.txid | shortenString : 16}} -
Value
Fee{{ previewTx.fee | number }} sat
Fee rate - {{ (previewTx.fee / previewTx.vsize) | feeRounding }} sat/vB -
Virtual size
Median fee~{{ mempoolBlock.medianFee | number:'1.0-0' }} sat/vB
Fee span{{ mempoolBlock.feeRange[0] | number:'1.0-0' }} - {{ mempoolBlock.feeRange[mempoolBlock.feeRange.length - 1] | number:'1.0-0' }} sat/vB
Total fees
Transactions{{ mempoolBlock.nTx }}
Size +
+
+
+
+
diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 5886be5d3..1a799086b 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -46,6 +46,7 @@ import { TransactionComponent } from '../components/transaction/transaction.comp import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; import { BlockComponent } from '../components/block/block.component'; import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; +import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; import { AddressComponent } from '../components/address/address.component'; import { SearchFormComponent } from '../components/search-form/search-form.component'; import { AddressLabelsComponent } from '../components/address-labels/address-labels.component'; @@ -112,6 +113,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen TransactionComponent, BlockComponent, BlockOverviewGraphComponent, + BlockOverviewTooltipComponent, TransactionsListComponent, AddressComponent, SearchFormComponent, @@ -206,6 +208,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen TransactionComponent, BlockComponent, BlockOverviewGraphComponent, + BlockOverviewTooltipComponent, TransactionsListComponent, AddressComponent, SearchFormComponent, From 288bddcaf2385d3eac98dc1c5ec708b16fcd9ff0 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 15 Jun 2022 19:53:37 +0000 Subject: [PATCH 5/8] Add API endpoint for block summary data --- .../src/api/bitcoin/bitcoin-api.interface.ts | 8 +++ backend/src/api/blocks.ts | 52 ++++++++++++++++++- backend/src/api/disk-cache.ts | 2 + backend/src/index.ts | 3 +- backend/src/mempool.interfaces.ts | 5 ++ backend/src/routes.ts | 10 ++++ 6 files changed, 77 insertions(+), 3 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin-api.interface.ts b/backend/src/api/bitcoin/bitcoin-api.interface.ts index 6a22af9a0..54d666794 100644 --- a/backend/src/api/bitcoin/bitcoin-api.interface.ts +++ b/backend/src/api/bitcoin/bitcoin-api.interface.ts @@ -73,6 +73,14 @@ export namespace IBitcoinApi { time: number; // (numeric) Same as blocktime } + export interface VerboseBlock extends Block { + tx: VerboseTransaction[]; // The transactions in the format of the getrawtransaction RPC. Different from verbosity = 1 "tx" result + } + + export interface VerboseTransaction extends Transaction { + fee?: number; // (numeric) The transaction fee in BTC, omitted if block undo data is not available + } + export interface Vin { txid?: string; // (string) The transaction id vout?: number; // (string) diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 92062f2b4..c013bbdd3 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -2,11 +2,12 @@ import config from '../config'; import bitcoinApi from './bitcoin/bitcoin-api-factory'; import logger from '../logger'; import memPool from './mempool'; -import { BlockExtended, PoolTag, TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces'; +import { BlockExtended, BlockSummary, PoolTag, TransactionExtended, TransactionStripped, TransactionMinerInfo } from '../mempool.interfaces'; import { Common } from './common'; import diskCache from './disk-cache'; import transactionUtils from './transaction-utils'; import bitcoinClient from './bitcoin/bitcoin-client'; +import { IBitcoinApi } from './bitcoin/bitcoin-api.interface'; import { IEsploraApi } from './bitcoin/esplora-api.interface'; import poolsRepository from '../repositories/PoolsRepository'; import blocksRepository from '../repositories/BlocksRepository'; @@ -22,6 +23,7 @@ import poolsParser from './pools-parser'; class Blocks { private blocks: BlockExtended[] = []; + private blockSummaries: BlockSummary[] = []; private currentBlockHeight = 0; private currentDifficulty = 0; private lastDifficultyAdjustmentTime = 0; @@ -38,6 +40,14 @@ class Blocks { this.blocks = blocks; } + public getBlockSummaries(): BlockSummary[] { + return this.blockSummaries; + } + + public setBlockSummaries(blockSummaries: BlockSummary[]) { + this.blockSummaries = blockSummaries; + } + public setNewBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void) { this.newBlockCallbacks.push(fn); } @@ -106,6 +116,27 @@ class Blocks { return transactions; } + /** + * Return a block summary (list of stripped transactions) + * @param block + * @returns BlockSummary + */ + private summarizeBlock(block: IBitcoinApi.VerboseBlock): BlockSummary { + const stripped = block.tx.map((tx) => { + return { + txid: tx.txid, + vsize: tx.vsize, + fee: tx.fee ? Math.round(tx.fee * 100000000) : 0, + value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0) * 100000000) + }; + }); + + return { + id: block.hash, + transactions: stripped + }; + } + /** * Return a block with additional data (reward, coinbase, fees...) * @param block @@ -341,10 +372,12 @@ class Blocks { } const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight); - const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash)); + const verboseBlock = await bitcoinClient.getBlock(blockHash, 2); + const block = BitcoinApi.convertBlock(verboseBlock); const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash); const transactions = await this.$getTransactionsExtended(blockHash, block.height, false); const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions); + const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock); if (Common.indexingEnabled()) { if (!fastForwarded) { @@ -375,6 +408,10 @@ class Blocks { if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) { this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4); } + this.blockSummaries.push(blockSummary); + if (this.blockSummaries.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) { + this.blockSummaries = this.blockSummaries.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4); + } if (this.newBlockCallbacks.length) { this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions)); @@ -440,6 +477,17 @@ class Blocks { return blockExtended; } + public async $getStrippedBlockTransactions(hash: string): Promise { + // Check the memory cache + const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); + if (cachedSummary) { + return cachedSummary.transactions; + } + const block = await bitcoinClient.getBlock(hash, 2); + const summary = this.summarizeBlock(block); + return summary.transactions; + } + public async $getBlocks(fromHeight?: number, limit: number = 15): Promise { try { let currentHeight = fromHeight !== undefined ? fromHeight : this.getCurrentBlockHeight(); diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts index 78c6b6c09..fc185a31a 100644 --- a/backend/src/api/disk-cache.ts +++ b/backend/src/api/disk-cache.ts @@ -43,6 +43,7 @@ class DiskCache { await fsPromises.writeFile(DiskCache.FILE_NAME, JSON.stringify({ cacheSchemaVersion: this.cacheSchemaVersion, blocks: blocks.getBlocks(), + blockSummaries: blocks.getBlockSummaries(), mempool: {}, mempoolArray: mempoolArray.splice(0, chunkSize), }), {flag: 'w'}); @@ -109,6 +110,7 @@ class DiskCache { memPool.setMempool(data.mempool); blocks.setBlocks(data.blocks); + blocks.setBlockSummaries(data.blockSummaries || []); } catch (e) { logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e)); } diff --git a/backend/src/index.ts b/backend/src/index.ts index d421a6fba..6bd8de841 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -314,7 +314,8 @@ class Server { this.app .get(config.MEMPOOL.API_URL_PREFIX + 'blocks', routes.getBlocks.bind(routes)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', routes.getBlocks.bind(routes)) - .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', routes.getBlock); + .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', routes.getBlock) + .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', routes.getStrippedBlockTransactions); if (config.MEMPOOL.BACKEND !== 'esplora') { this.app diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 8d0fa6972..a35dc6d76 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -106,6 +106,11 @@ export interface BlockExtended extends IEsploraApi.Block { extras: BlockExtension; } +export interface BlockSummary { + id: string; + transactions: TransactionStripped[]; +} + export interface TransactionMinerInfo { vin: VinStrippedToScriptsig[]; vout: VoutStrippedToScriptPubkey[]; diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 12368b1ee..99f54a9f8 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -726,6 +726,16 @@ class Routes { } } + public async getStrippedBlockTransactions(req: Request, res: Response) { + try { + const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); + res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); + res.json(transactions); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + public async getBlocks(req: Request, res: Response) { try { if (['mainnet', 'testnet', 'signet', 'regtest'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin From 72a603ac370c7b23121abb581041a8a6fde3c134 Mon Sep 17 00:00:00 2001 From: nymkappa Date: Sat, 18 Jun 2022 16:48:02 +0200 Subject: [PATCH 6/8] Index block summaries in db --- backend/mempool-config.sample.json | 1 + backend/src/api/blocks.ts | 83 +++++++++++++++++-- backend/src/api/common.ts | 7 ++ backend/src/api/database-migration.ts | 15 +++- backend/src/config.ts | 2 + backend/src/indexer.ts | 1 + backend/src/repositories/BlocksRepository.ts | 13 +++ .../repositories/BlocksSummariesRepository.ts | 46 ++++++++++ docker/backend/mempool-config.json | 3 +- docker/backend/start.sh | 2 + production/mempool-config.mainnet.json | 1 + 11 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 backend/src/repositories/BlocksSummariesRepository.ts diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 77b571136..eedbf3e4c 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -13,6 +13,7 @@ "INITIAL_BLOCKS_AMOUNT": 8, "MEMPOOL_BLOCKS_AMOUNT": 8, "INDEXING_BLOCKS_AMOUNT": 11000, + "BLOCKS_SUMMARIES_INDEXING": false, "PRICE_FEED_UPDATE_INTERVAL": 600, "USE_SECOND_NODE_FOR_MINFEE": false, "EXTERNAL_ASSETS": [], diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index c013bbdd3..33b8c93ba 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -20,6 +20,7 @@ import indexer from '../indexer'; import fiatConversion from './fiat-conversion'; import RatesRepository from '../repositories/RatesRepository'; import poolsParser from './pools-parser'; +import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; class Blocks { private blocks: BlockExtended[] = []; @@ -242,6 +243,53 @@ class Blocks { } } + /** + * [INDEXING] Index all blocks summaries for the block txs visualization + */ + public async $generateBlocksSummariesDatabase() { + if (Common.blocksSummariesIndexingEnabled() === false) { + return; + } + + try { + // Get all indexed block hash + const indexedBlocks = await blocksRepository.$getIndexedBlocks(); + const indexedBlockSummariesHashes = await BlocksSummariesRepository.$getIndexedSummariesId(); + + // Logging + let totalIndexed = indexedBlockSummariesHashes.length; + let indexedThisRun = 0; + let timer = new Date().getTime() / 1000; + const startedAt = new Date().getTime() / 1000; + + for (const block of indexedBlocks) { + if (indexedBlockSummariesHashes.includes(block.hash)) { + continue; + } + + // Logging + const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); + if (elapsedSeconds > 5) { + const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); + const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds); + const progress = Math.round(totalIndexed / indexedBlocks.length * 10000) / 100; + const timeLeft = Math.round((indexedBlocks.length - totalIndexed) / blockPerSeconds); + logger.debug(`Indexing block summary for #${block.height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexedBlocks.length} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`); + timer = new Date().getTime() / 1000; + indexedThisRun = 0; + } + + await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary + + // Logging + indexedThisRun++; + totalIndexed++; + } + } catch (e) { + logger.err(`Blocks summaries indexing failed. Reason: ${(e instanceof Error ? e.message : e)}`); + } + } + /** * [INDEXING] Index all blocks metadata for the mining dashboard */ @@ -292,7 +340,7 @@ class Blocks { const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) { const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); - const blockPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds)); + const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds); const progress = Math.round(totalIndexed / indexingBlockAmount * 10000) / 100; const timeLeft = Math.round((indexingBlockAmount - totalIndexed) / blockPerSeconds); logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`); @@ -392,6 +440,11 @@ class Blocks { } } await blocksRepository.$saveBlockInDatabase(blockExtended); + + // Save blocks summary for visualization if it's enabled + if (Common.blocksSummariesIndexingEnabled() === true) { + await this.$getStrippedBlockTransactions(blockExtended.id, true); + } } } if (fiatConversion.ratesInitialized === true && config.DATABASE.ENABLED === true) { @@ -477,14 +530,32 @@ class Blocks { return blockExtended; } - public async $getStrippedBlockTransactions(hash: string): Promise { - // Check the memory cache - const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); - if (cachedSummary) { - return cachedSummary.transactions; + public async $getStrippedBlockTransactions(hash: string, skipMemoryCache: boolean = false, skipDBLookup: boolean = false): Promise { + if (skipMemoryCache === false) { + // Check the memory cache + const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); + if (cachedSummary) { + return cachedSummary.transactions; + } } + + // Check if it's indexed in db + if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) { + const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash); + if (indexedSummary !== undefined) { + return indexedSummary.transactions; + } + } + + // Call Core RPC const block = await bitcoinClient.getBlock(hash, 2); const summary = this.summarizeBlock(block); + + // Index the response if needed + if (Common.blocksSummariesIndexingEnabled() === true) { + await BlocksSummariesRepository.$saveSummary(block.height, summary); + } + return summary.transactions; } diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index d4b57f204..d1c8cecbb 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -177,4 +177,11 @@ export class Common { config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== 0 ); } + + static blocksSummariesIndexingEnabled(): boolean { + return ( + Common.indexingEnabled() && + config.MEMPOOL.BLOCKS_SUMMARIES_INDEXING === true + ); + } } diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 5ac63740b..901381d79 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -4,7 +4,7 @@ import logger from '../logger'; import { Common } from './common'; class DatabaseMigration { - private static currentVersion = 19; + private static currentVersion = 20; private queryTimeout = 120000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -217,6 +217,10 @@ class DatabaseMigration { if (databaseSchemaVersion < 19) { await this.$executeQuery(this.getCreateRatesTableQuery(), await this.$checkIfTableExists('rates')); } + + if (databaseSchemaVersion < 20 && isBitcoin === true) { + await this.$executeQuery(this.getCreateBlocksSummariesTableQuery(), await this.$checkIfTableExists('blocks_summaries')); + } } catch (e) { throw e; } @@ -512,6 +516,15 @@ class DatabaseMigration { ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; } + private getCreateBlocksSummariesTableQuery(): string { + return `CREATE TABLE IF NOT EXISTS blocks_summaries ( + height int(10) unsigned NOT NULL, + id varchar(65) NOT NULL, + transactions JSON NOT NULL, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + } + public async $truncateIndexedData(tables: string[]) { const allowedTables = ['blocks', 'hashrates']; diff --git a/backend/src/config.ts b/backend/src/config.ts index e49da3dc9..44864d3b9 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -15,6 +15,7 @@ interface IConfig { INITIAL_BLOCKS_AMOUNT: number; MEMPOOL_BLOCKS_AMOUNT: number; INDEXING_BLOCKS_AMOUNT: number; + BLOCKS_SUMMARIES_INDEXING: boolean; PRICE_FEED_UPDATE_INTERVAL: number; USE_SECOND_NODE_FOR_MINFEE: boolean; EXTERNAL_ASSETS: string[]; @@ -104,6 +105,7 @@ const defaults: IConfig = { 'INITIAL_BLOCKS_AMOUNT': 8, 'MEMPOOL_BLOCKS_AMOUNT': 8, 'INDEXING_BLOCKS_AMOUNT': 11000, // 0 = disable indexing, -1 = index all blocks + 'BLOCKS_SUMMARIES_INDEXING': false, 'PRICE_FEED_UPDATE_INTERVAL': 600, 'USE_SECOND_NODE_FOR_MINFEE': false, 'EXTERNAL_ASSETS': [], diff --git a/backend/src/indexer.ts b/backend/src/indexer.ts index 04c51ffe2..d46991707 100644 --- a/backend/src/indexer.ts +++ b/backend/src/indexer.ts @@ -33,6 +33,7 @@ class Indexer { await this.$resetHashratesIndexingState(); await mining.$generateNetworkHashrateHistory(); await mining.$generatePoolHashrateHistory(); + await blocks.$generateBlocksSummariesDatabase(); } catch (e) { this.reindex(); logger.err(`Indexer failed, trying again later. Reason: ` + (e instanceof Error ? e.message : e)); diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 4900e3bba..bd39a4494 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -652,6 +652,19 @@ class BlocksRepository { throw e; } } + + /** + * Get a list of blocks that have been indexed + */ + public async $getIndexedBlocks(): Promise { + try { + const [rows]: any = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`); + return rows; + } catch (e) { + logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } } export default new BlocksRepository(); diff --git a/backend/src/repositories/BlocksSummariesRepository.ts b/backend/src/repositories/BlocksSummariesRepository.ts new file mode 100644 index 000000000..73b43ed6f --- /dev/null +++ b/backend/src/repositories/BlocksSummariesRepository.ts @@ -0,0 +1,46 @@ +import DB from '../database'; +import logger from '../logger'; +import { BlockSummary } from '../mempool.interfaces'; + +class BlocksSummariesRepository { + public async $getByBlockId(id: string): Promise { + try { + const [summary]: any[] = await DB.query(`SELECT * from blocks_summaries WHERE id = ?`, [id]); + if (summary.length > 0) { + summary[0].transactions = JSON.parse(summary[0].transactions); + return summary[0]; + } + } catch (e) { + logger.err(`Cannot get block summary for block id ${id}. Reason: ` + (e instanceof Error ? e.message : e)); + } + + return undefined; + } + + public async $saveSummary(height: number, summary: BlockSummary) { + try { + await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?)`, [height, summary.id, JSON.stringify(summary.transactions)]); + } catch (e: any) { + if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart + logger.debug(`Cannot save block summary for ${summary.id} because it has already been indexed, ignoring`); + } else { + logger.debug(`Cannot save block summary for ${summary.id}. Reason: ${e instanceof Error ? e.message : e}`); + throw e; + } + } + } + + public async $getIndexedSummariesId(): Promise { + try { + const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries`); + return rows.map(row => row.id); + } catch (e) { + logger.err(`Cannot get block summaries id list. Reason: ` + (e instanceof Error ? e.message : e)); + } + + return []; + } +} + +export default new BlocksSummariesRepository(); + diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index 9a929a4f0..daa4fddc3 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -19,7 +19,8 @@ "EXTERNAL_RETRY_INTERVAL": __MEMPOOL_EXTERNAL_RETRY_INTERVAL__, "USER_AGENT": "__MEMPOOL_USER_AGENT__", "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__", - "INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__ + "INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__, + "BLOCKS_SUMMARIES_INDEXING": __MEMPOOL_BLOCKS_SUMMARIES_INDEXING__ }, "CORE_RPC": { "HOST": "__CORE_RPC_HOST__", diff --git a/docker/backend/start.sh b/docker/backend/start.sh index 5792da008..5c4213a1c 100644 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -14,6 +14,7 @@ __MEMPOOL_BLOCK_WEIGHT_UNITS__=${MEMPOOL_BLOCK_WEIGHT_UNITS:=4000000} __MEMPOOL_INITIAL_BLOCKS_AMOUNT__=${MEMPOOL_INITIAL_BLOCKS_AMOUNT:=8} __MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_MEMPOOL_BLOCKS_AMOUNT:=8} __MEMPOOL_INDEXING_BLOCKS_AMOUNT__=${MEMPOOL_INDEXING_BLOCKS_AMOUNT:=11000} +__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__=${MEMPOOL_BLOCKS_SUMMARIES_INDEXING:=false} __MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__=${MEMPOOL_PRICE_FEED_UPDATE_INTERVAL:=600} __MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__=${MEMPOOL_USE_SECOND_NODE_FOR_MINFEE:=false} __MEMPOOL_EXTERNAL_ASSETS__=${MEMPOOL_EXTERNAL_ASSETS:=[]} @@ -101,6 +102,7 @@ sed -i "s/__MEMPOOL_BLOCK_WEIGHT_UNITS__/${__MEMPOOL_BLOCK_WEIGHT_UNITS__}/g" me sed -i "s/__MEMPOOL_INITIAL_BLOCKS_AMOUNT__/${__MEMPOOL_INITIAL_BLOCKS_AMOUNT__}/g" mempool-config.json sed -i "s/__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__/${__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__}/g" mempool-config.json sed -i "s/__MEMPOOL_INDEXING_BLOCKS_AMOUNT__/${__MEMPOOL_INDEXING_BLOCKS_AMOUNT__}/g" mempool-config.json +sed -i "s/__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__/${__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__}/g" mempool-config.json sed -i "s/__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__/${__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__}/g" mempool-config.json sed -i "s/__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__/${__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__}/g" mempool-config.json sed -i "s!__MEMPOOL_EXTERNAL_ASSETS__!${__MEMPOOL_EXTERNAL_ASSETS__}!g" mempool-config.json diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json index 1cfeab20d..4575afdbe 100644 --- a/production/mempool-config.mainnet.json +++ b/production/mempool-config.mainnet.json @@ -9,6 +9,7 @@ "CLEAR_PROTECTION_MINUTES": 5, "POLL_RATE_MS": 1000, "INDEXING_BLOCKS_AMOUNT": -1, + "BLOCKS_SUMMARIES_INDEXING": true, "USE_SECOND_NODE_FOR_MINFEE": true }, "SYSLOG" : { From aa86885e6bc497844227381c7fecf9b9db7d9944 Mon Sep 17 00:00:00 2001 From: nymkappa Date: Mon, 20 Jun 2022 16:35:10 +0200 Subject: [PATCH 7/8] Set `block/:hash/summary` expiration to 1 month - Support re-org for block summaries --- backend/src/api/blocks.ts | 32 ++++++++++++++----- backend/src/api/database-migration.ts | 5 +-- backend/src/indexer.ts | 8 ++++- backend/src/repositories/BlocksRepository.ts | 2 ++ .../repositories/BlocksSummariesRepository.ts | 13 ++++++++ backend/src/routes.ts | 2 +- frontend/src/app/services/api.service.ts | 2 +- 7 files changed, 51 insertions(+), 13 deletions(-) diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 33b8c93ba..571cc0f3b 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -254,16 +254,22 @@ class Blocks { try { // Get all indexed block hash const indexedBlocks = await blocksRepository.$getIndexedBlocks(); - const indexedBlockSummariesHashes = await BlocksSummariesRepository.$getIndexedSummariesId(); + const indexedBlockSummariesHashesArray = await BlocksSummariesRepository.$getIndexedSummariesId(); + + const indexedBlockSummariesHashes = {}; // Use a map for faster seek during the indexing loop + for (const hash of indexedBlockSummariesHashesArray) { + indexedBlockSummariesHashes[hash] = true; + } // Logging - let totalIndexed = indexedBlockSummariesHashes.length; + let newlyIndexed = 0; + let totalIndexed = indexedBlockSummariesHashesArray.length; let indexedThisRun = 0; let timer = new Date().getTime() / 1000; const startedAt = new Date().getTime() / 1000; for (const block of indexedBlocks) { - if (indexedBlockSummariesHashes.includes(block.hash)) { + if (indexedBlockSummariesHashes[block.hash] === true) { continue; } @@ -284,7 +290,9 @@ class Blocks { // Logging indexedThisRun++; totalIndexed++; + newlyIndexed++; } + logger.notice(`Blocks summaries indexing completed: indexed ${newlyIndexed} blocks`); } catch (e) { logger.err(`Blocks summaries indexing failed. Reason: ${(e instanceof Error ? e.message : e)}`); } @@ -293,10 +301,10 @@ class Blocks { /** * [INDEXING] Index all blocks metadata for the mining dashboard */ - public async $generateBlockDatabase() { + public async $generateBlockDatabase(): Promise { const blockchainInfo = await bitcoinClient.getBlockchainInfo(); if (blockchainInfo.blocks !== blockchainInfo.headers) { // Wait for node to sync - return; + return false; } try { @@ -364,13 +372,16 @@ class Blocks { } catch (e) { logger.err('Block indexing failed. Trying again later. Reason: ' + (e instanceof Error ? e.message : e)); loadingIndicators.setProgress('block-indexing', 100); - return; + return false; } const chainValid = await BlocksRepository.$validateChain(); if (!chainValid) { indexer.reindex(); + return false; } + + return true; } public async $updateBlocks() { @@ -435,9 +446,12 @@ class Blocks { // We assume there won't be a reorg with more than 10 block depth await BlocksRepository.$deleteBlocksFrom(lastBlock['height'] - 10); await HashratesRepository.$deleteLastEntries(); + await BlocksSummariesRepository.$deleteBlocksFrom(lastBlock['height'] - 10); for (let i = 10; i >= 0; --i) { - await this.$indexBlock(lastBlock['height'] - i); + const newBlock = await this.$indexBlock(lastBlock['height'] - i); + await this.$getStrippedBlockTransactions(newBlock.id, true, true); } + logger.info(`Re-indexed 10 blocks and summaries`); } await blocksRepository.$saveBlockInDatabase(blockExtended); @@ -530,7 +544,9 @@ class Blocks { return blockExtended; } - public async $getStrippedBlockTransactions(hash: string, skipMemoryCache: boolean = false, skipDBLookup: boolean = false): Promise { + public async $getStrippedBlockTransactions(hash: string, skipMemoryCache: boolean = false, + skipDBLookup: boolean = false): Promise + { if (skipMemoryCache === false) { // Check the memory cache const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 901381d79..2b7b6ddea 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -521,8 +521,9 @@ class DatabaseMigration { height int(10) unsigned NOT NULL, id varchar(65) NOT NULL, transactions JSON NOT NULL, - PRIMARY KEY (id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + PRIMARY KEY (id), + INDEX (height) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; } public async $truncateIndexedData(tables: string[]) { diff --git a/backend/src/indexer.ts b/backend/src/indexer.ts index d46991707..96cca9f7f 100644 --- a/backend/src/indexer.ts +++ b/backend/src/indexer.ts @@ -29,7 +29,13 @@ class Indexer { this.indexerRunning = true; try { - await blocks.$generateBlockDatabase(); + const chainValid = await blocks.$generateBlockDatabase(); + if (chainValid === false) { + // Chain of block hash was invalid, so we need to reindex. Stop here and continue at the next iteration + this.indexerRunning = false; + return; + } + await this.$resetHashratesIndexingState(); await mining.$generateNetworkHashrateHistory(); await mining.$generatePoolHashrateHistory(); diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index bd39a4494..01b7622f3 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -6,6 +6,7 @@ import { prepareBlock } from '../utils/blocks-utils'; import PoolsRepository from './PoolsRepository'; import HashratesRepository from './HashratesRepository'; import { escape } from 'mysql2'; +import BlocksSummariesRepository from './BlocksSummariesRepository'; class BlocksRepository { /** @@ -495,6 +496,7 @@ class BlocksRepository { if (blocks[idx].previous_block_hash !== blocks[idx - 1].hash) { logger.warn(`Chain divergence detected at block ${blocks[idx - 1].height}, re-indexing newer blocks and hashrates`); await this.$deleteBlocksFrom(blocks[idx - 1].height); + await BlocksSummariesRepository.$deleteBlocksFrom(blocks[idx - 1].height); await HashratesRepository.$deleteHashratesFromTimestamp(blocks[idx - 1].timestamp - 604800); return false; } diff --git a/backend/src/repositories/BlocksSummariesRepository.ts b/backend/src/repositories/BlocksSummariesRepository.ts index 73b43ed6f..66c6b97f2 100644 --- a/backend/src/repositories/BlocksSummariesRepository.ts +++ b/backend/src/repositories/BlocksSummariesRepository.ts @@ -40,6 +40,19 @@ class BlocksSummariesRepository { return []; } + + /** + * Delete blocks from the database from blockHeight + */ + public async $deleteBlocksFrom(blockHeight: number) { + logger.info(`Delete newer blocks summary from height ${blockHeight} from the database`); + + try { + await DB.query(`DELETE FROM blocks_summaries where height >= ${blockHeight}`); + } catch (e) { + logger.err('Cannot delete indexed blocks summaries. Reason: ' + (e instanceof Error ? e.message : e)); + } + } } export default new BlocksSummariesRepository(); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 99f54a9f8..e63549d09 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -729,7 +729,7 @@ class Routes { public async getStrippedBlockTransactions(req: Request, res: Response) { try { const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); - res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); + res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(transactions); } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 8202fbb49..42c942dad 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, - PoolsStats, PoolStat, BlockExtended, TransactionStripped, RewardStats } from '../interfaces/node-api.interface'; + PoolStat, BlockExtended, TransactionStripped, RewardStats } from '../interfaces/node-api.interface'; import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; From 9d1883f9257d4b957bd727b3fc6a5820fcedea84 Mon Sep 17 00:00:00 2001 From: wiz Date: Tue, 21 Jun 2022 22:27:40 +0900 Subject: [PATCH 8/8] [ops] Cache /api/block and /api/v1/block for 1 month --- production/nginx/location-api.conf | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/production/nginx/location-api.conf b/production/nginx/location-api.conf index 253033206..9c88afc6e 100644 --- a/production/nginx/location-api.conf +++ b/production/nginx/location-api.conf @@ -4,14 +4,42 @@ location /api/v1/statistics { location /api/v1/mining { try_files /dev/null @mempool-api-v1-warmcache; } +location /api/v1/block { + try_files /dev/null @mempool-api-v1-forevercache; +} location /api/v1 { try_files /dev/null @mempool-api-v1-coldcache; } +location /api/block { + rewrite ^/api/(.*) /$1 break; + try_files /dev/null @electrs-api-forevercache; +} location /api/ { rewrite ^/api/(.*) /$1 break; try_files /dev/null @electrs-api-nocache; } +location @mempool-api-v1-forevercache { + proxy_pass $mempoolBackend; + proxy_http_version 1.1; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_cache_bypass $http_upgrade; + proxy_cache_background_update on; + proxy_cache_use_stale updating; + proxy_cache api; + proxy_cache_valid 200 30d; + proxy_redirect off; + + expires 30d; +} + location @mempool-api-v1-warmcache { proxy_pass $mempoolBackend; proxy_http_version 1.1; @@ -46,6 +74,7 @@ location @mempool-api-v1-coldcache { proxy_cache api; proxy_cache_valid 200 10s; proxy_redirect off; + expires 10s; } @@ -81,4 +110,27 @@ location @electrs-api-nocache { proxy_cache_bypass $http_upgrade; proxy_redirect off; proxy_buffering off; + + expires -1; +} + +location @electrs-api-forevercache { + proxy_pass $electrsBackend; + proxy_http_version 1.1; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_cache_bypass $http_upgrade; + proxy_cache_background_update on; + proxy_cache_use_stale updating; + proxy_cache api; + proxy_cache_valid 200 30d; + proxy_redirect off; + + expires 30d; }