diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index d0d8aa582..d202c3a44 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -1,10 +1,11 @@ import logger from '../logger'; -import { MempoolBlock, TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces'; +import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta } from '../mempool.interfaces'; import { Common } from './common'; import config from '../config'; class MempoolBlocks { private mempoolBlocks: MempoolBlockWithTransactions[] = []; + private mempoolBlockDeltas: MempoolBlockDelta[] = []; constructor() {} @@ -25,6 +26,10 @@ class MempoolBlocks { return this.mempoolBlocks; } + public getMempoolBlockDeltas(): MempoolBlockDelta[] { + return this.mempoolBlockDeltas + } + public updateMempoolBlocks(memPool: { [txid: string]: TransactionExtended }): void { const latestMempool = memPool; const memPoolArray: TransactionExtended[] = []; @@ -66,11 +71,14 @@ class MempoolBlocks { const time = end - start; logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds'); - this.mempoolBlocks = this.calculateMempoolBlocks(memPoolArray); + const { blocks, deltas } = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks); + this.mempoolBlocks = blocks + this.mempoolBlockDeltas = deltas } - private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] { + private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]): { blocks: MempoolBlockWithTransactions[], deltas: MempoolBlockDelta[] } { const mempoolBlocks: MempoolBlockWithTransactions[] = []; + const mempoolBlockDeltas: MempoolBlockDelta[] = []; let blockWeight = 0; let blockSize = 0; let transactions: TransactionExtended[] = []; @@ -90,7 +98,39 @@ class MempoolBlocks { if (transactions.length) { mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length)); } - return mempoolBlocks; + // Calculate change from previous block states + for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { + let added: TransactionStripped[] = [] + let removed: string[] = [] + if (mempoolBlocks[i] && !prevBlocks[i]) { + added = mempoolBlocks[i].transactions + } else if (!mempoolBlocks[i] && prevBlocks[i]) { + removed = prevBlocks[i].transactions.map(tx => tx.txid) + } else if (mempoolBlocks[i] && prevBlocks[i]) { + const prevIds = {} + const newIds = {} + prevBlocks[i].transactions.forEach(tx => { + prevIds[tx.txid] = true + }) + mempoolBlocks[i].transactions.forEach(tx => { + newIds[tx.txid] = true + }) + prevBlocks[i].transactions.forEach(tx => { + if (!newIds[tx.txid]) removed.push(tx.txid) + }) + mempoolBlocks[i].transactions.forEach(tx => { + if (!prevIds[tx.txid]) added.push(tx) + }) + } + mempoolBlockDeltas.push({ + added, + removed + }) + } + return { + blocks: mempoolBlocks, + deltas: mempoolBlockDeltas + } } private dataToMempoolBlocks(transactions: TransactionExtended[], @@ -112,6 +152,7 @@ class MempoolBlocks { medianFee: Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE), feeRange: Common.getFeesInRange(transactions, rangeLength), transactionIds: transactions.map((tx) => tx.txid), + transactions: transactions.map((tx) => Common.stripTransaction(tx)), }; } } diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index c305a4ae1..341b09e7f 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -1,7 +1,6 @@ import logger from '../logger'; import * as WebSocket from 'ws'; -import { BlockExtended, TransactionExtended, WebsocketResponse, MempoolBlock, - OptimizedStatistic, ILoadingIndicators, IConversionRates } from '../mempool.interfaces'; +import { BlockExtended, TransactionExtended, WebsocketResponse, MempoolBlock, MempoolBlockDelta, OptimizedStatistic, ILoadingIndicators, IConversionRates } from '../mempool.interfaces'; import blocks from './blocks'; import memPool from './mempool'; import backendInfo from './backend-info'; @@ -111,6 +110,22 @@ class WebsocketHandler { } } + if (parsedMessage && parsedMessage['track-mempool-block'] != null) { + if (Number.isInteger(parsedMessage['track-mempool-block']) && parsedMessage['track-mempool-block'] >= 0) { + const index = parsedMessage['track-mempool-block']; + client['track-mempool-block'] = index; + const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); + if (mBlocksWithTransactions[index]) { + response['projected-mempool-block'] = { + index: index, + block: mBlocksWithTransactions[index], + }; + } + } else { + client['track-mempool-block'] = null; + } + } + if (parsedMessage.action === 'init') { const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); if (!_blocks) { @@ -233,6 +248,7 @@ class WebsocketHandler { mempoolBlocks.updateMempoolBlocks(newMempool); const mBlocks = mempoolBlocks.getMempoolBlocks(); + const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); const mempoolInfo = memPool.getMempoolInfo(); const vBytesPerSecond = memPool.getVBytesPerSecond(); const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); @@ -370,6 +386,16 @@ class WebsocketHandler { } } + if (client['track-mempool-block'] >= 0) { + const index = client['track-mempool-block']; + if (mBlockDeltas[index]) { + response['projected-mempool-block'] = { + index: index, + delta: mBlockDeltas[index], + }; + } + } + if (Object.keys(response).length) { client.send(JSON.stringify(response)); } @@ -382,6 +408,7 @@ class WebsocketHandler { } let mBlocks: undefined | MempoolBlock[]; + let mBlockDeltas: undefined | MempoolBlockDelta[]; let matchRate = 0; const _memPool = memPool.getMempool(); const _mempoolBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); @@ -398,6 +425,7 @@ class WebsocketHandler { matchRate = Math.round((matches.length / (txIds.length - 1)) * 100); mempoolBlocks.updateMempoolBlocks(_memPool); mBlocks = mempoolBlocks.getMempoolBlocks(); + mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); } if (block.extras) { @@ -495,6 +523,16 @@ class WebsocketHandler { } } + if (client['track-mempool-block'] >= 0) { + const index = client['track-mempool-block']; + if (mBlockDeltas && mBlockDeltas[index]) { + response['projected-mempool-block'] = { + index: index, + delta: mBlockDeltas[index], + }; + } + } + client.send(JSON.stringify(response)); }); } diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 60b07da1b..8d0fa6972 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -33,6 +33,12 @@ export interface MempoolBlock { export interface MempoolBlockWithTransactions extends MempoolBlock { transactionIds: string[]; + transactions: TransactionStripped[]; +} + +export interface MempoolBlockDelta { + added: TransactionStripped[]; + removed: string[]; } interface VinStrippedToScriptsig { diff --git a/contributors/mononaut.txt b/contributors/mononaut.txt new file mode 100644 index 000000000..fcd535c8e --- /dev/null +++ b/contributors/mononaut.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of May 31, 2022. + +Signed: mononaut diff --git a/frontend/src/app/components/mempool-block-overview/block-scene.ts b/frontend/src/app/components/mempool-block-overview/block-scene.ts new file mode 100644 index 000000000..5e781e429 --- /dev/null +++ b/frontend/src/app/components/mempool-block-overview/block-scene.ts @@ -0,0 +1,683 @@ +import { FastVertexArray } from './fast-vertex-array' +import TxSprite from './tx-sprite' +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 = Math.floor(Math.max(1, width / 1000)) + 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/fast-vertex-array.ts b/frontend/src/app/components/mempool-block-overview/fast-vertex-array.ts new file mode 100644 index 000000000..6bd025fdd --- /dev/null +++ b/frontend/src/app/components/mempool-block-overview/fast-vertex-array.ts @@ -0,0 +1,103 @@ +/* + Utility class for access and management of low-level sprite data + + Maintains a single Float32Array of sprite data, keeping track of empty slots + to allow constant-time insertion and deletion + + Automatically resizes by copying to a new, larger Float32Array when necessary, + or compacting into a smaller Float32Array when there's space to do so. +*/ + +import TxSprite from './tx-sprite' + +export class FastVertexArray { + length: number; + count: number; + stride: number; + sprites: TxSprite[]; + data: Float32Array; + freeSlots: number[]; + lastSlot: number; + + constructor (length, stride) { + this.length = length + this.count = 0 + this.stride = stride + this.sprites = [] + this.data = new Float32Array(this.length * this.stride) + this.freeSlots = [] + this.lastSlot = 0 + } + + insert (sprite: TxSprite): number { + this.count++ + + let position + if (this.freeSlots.length) { + position = this.freeSlots.shift() + } else { + position = this.lastSlot + this.lastSlot++ + if (this.lastSlot > this.length) { + this.expand() + } + } + this.sprites[position] = sprite + return position + } + + remove (index: number): void { + this.count-- + this.clearData(index) + this.freeSlots.push(index) + this.sprites[index] = null + if (this.length > 2048 && this.count < (this.length * 0.4)) this.compact() + } + + setData (index: number, dataChunk: number[]): void { + this.data.set(dataChunk, (index * this.stride)) + } + + clearData (index: number): void { + this.data.fill(0, (index * this.stride), ((index+1) * this.stride)) + } + + getData (index: number): Float32Array { + return this.data.subarray(index, this.stride) + } + + expand (): void { + this.length *= 2 + const newData = new Float32Array(this.length * this.stride) + newData.set(this.data) + this.data = newData + } + + compact (): void { + // New array length is the smallest power of 2 larger than the sprite count (but no smaller than 512) + const newLength = Math.max(512, Math.pow(2, Math.ceil(Math.log2(this.count)))) + if (newLength != this.length) { + this.length = newLength + this.data = new Float32Array(this.length * this.stride) + let sprite + const newSprites = [] + let i = 0 + for (var index in this.sprites) { + sprite = this.sprites[index] + if (sprite) { + newSprites.push(sprite) + sprite.moveVertexPointer(i) + sprite.compile() + i++ + } + } + this.sprites = newSprites + this.freeSlots = [] + this.lastSlot = i + } + } + + getVertexData (): Float32Array { + return this.data + } +} 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 new file mode 100644 index 000000000..6951120d7 --- /dev/null +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.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/mempool-block-overview/mempool-block-overview.component.scss new file mode 100644 index 000000000..8c3c271d1 --- /dev/null +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.scss @@ -0,0 +1,35 @@ +.mempool-block-overview { + position: relative; + width: 100%; + padding-bottom: 100%; + background: #181b2d; + display: flex; + justify-content: center; + align-items: center; +} + +.block-overview { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + overflow: hidden; +} + +.loader-wrapper { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + transition: opacity 500ms 500ms; + + &.hidden { + opacity: 0; + transition: opacity 500ms; + } +} 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 new file mode 100644 index 000000000..e75a78896 --- /dev/null +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -0,0 +1,389 @@ +import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, ChangeDetectionStrategy, NgZone } from '@angular/core'; +import { StateService } from 'src/app/services/state.service'; +import { MempoolBlockWithTransactions, MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface'; +import { Observable, Subscription, BehaviorSubject } from 'rxjs'; +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 { + @Input() index: number; + @Output() txPreviewEvent = new EventEmitter() + + @ViewChild('blockCanvas') + canvas: ElementRef; + + gl: WebGLRenderingContext; + animationFrameRequest: number; + displayWidth: number; + displayHeight: number; + shaderProgram: WebGLProgram; + vertexArray: FastVertexArray; + running: boolean; + scene: BlockScene; + hoverTx: TxView | void; + selectedTx: TxView | void; + lastBlockHeight: number; + blockIndex: number; + isLoading$ = new BehaviorSubject(true); + + blockSub: Subscription; + deltaSub: Subscription; + + constructor( + public stateService: StateService, + private websocketService: WebsocketService, + readonly _ngZone: NgZone, + ) { + this.vertexArray = new FastVertexArray(512, TxSprite.dataSize) + } + + ngOnInit(): void { + this.blockSub = this.stateService.mempoolBlock$.subscribe((block) => { + this.replaceBlock(block) + }) + this.deltaSub = this.stateService.mempoolBlockDelta$.subscribe((delta) => { + this.updateBlock(delta) + }) + } + + 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') + this.isLoading$.next(true) + this.websocketService.startTrackMempoolBlock(changes.index.currentValue); + } + } + + ngOnDestroy(): void { + this.blockSub.unsubscribe(); + this.deltaSub.unsubscribe(); + this.websocketService.stopTrackMempoolBlock(); + } + + clearBlock(direction): void { + if (this.scene) { + this.scene.exit(direction) + } + this.hoverTx = null + this.selectedTx = null + this.txPreviewEvent.emit(null) + } + + replaceBlock(block: MempoolBlockWithTransactions): 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(block.transactions, direction) + } else { + this.scene.replace(block.transactions, blockMined ? 'right' : 'left') + } + + this.lastBlockHeight = this.stateService.latestBlockHeight + this.blockIndex = this.index + this.isLoading$.next(false) + } + + 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) + } else { + this.scene.update(delta.added, delta.removed, blockMined ? 'right' : 'left', blockMined) + } + + this.lastBlockHeight = this.stateService.latestBlockHeight + this.blockIndex = this.index + 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.displayWidth = this.canvas.nativeElement.parentElement.clientWidth + this.displayHeight = this.canvas.nativeElement.parentElement.clientHeight + this.canvas.nativeElement.width = this.displayWidth + this.canvas.nativeElement.height = this.displayHeight + if (this.gl) this.gl.viewport(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height) + if (this.scene) this.scene.resize({ width: this.displayWidth, height: this.displayHeight }) + } + + compileShader(src, type): WebGLShader { + let 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 { + let program = this.gl.createProgram() + + shaderInfo.forEach((desc) => { + let 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(x: number, y: number, clicked: boolean = false) { + 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++) { + let 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-overview/sprite-types.ts b/frontend/src/app/components/mempool-block-overview/sprite-types.ts new file mode 100644 index 000000000..ef0778582 --- /dev/null +++ b/frontend/src/app/components/mempool-block-overview/sprite-types.ts @@ -0,0 +1,73 @@ +export type Position = { + x: number, + y: number, +} + +export type Square = Position & { + s?: number +} + +export type Color = { + r: number, + g: number, + b: number, + a: number, +} + +export type InterpolatedAttribute = { + a: number, + b: number, + t: number, + v: number, + d: number +} + +export type Update = Position & { s: number } & Color + +export type Attributes = { + x: InterpolatedAttribute, + y: InterpolatedAttribute, + s: InterpolatedAttribute, + r: InterpolatedAttribute, + g: InterpolatedAttribute, + b: InterpolatedAttribute, + a: InterpolatedAttribute +} + +export type OptionalAttributes = { + x?: InterpolatedAttribute, + y?: InterpolatedAttribute, + s?: InterpolatedAttribute, + r?: InterpolatedAttribute, + g?: InterpolatedAttribute, + b?: InterpolatedAttribute, + a?: InterpolatedAttribute +} +export type SpriteUpdateParams = { + x?: number, + y?: number, + s?: number, + r?: number, + g?: number, + b?: number, + a?: number + start?: DOMHighResTimeStamp, + duration?: number, + minDuration?: number, + adjust?: boolean, + temp?: boolean +} + +export type ViewUpdateParams = { + display: { + position?: Square, + color?: Color, + }, + start?: number, + duration?: number, + minDuration?: number, + delay?: number, + jitter?: number, + state?: string, + adjust?: boolean +} diff --git a/frontend/src/app/components/mempool-block-overview/tx-sprite.ts b/frontend/src/app/components/mempool-block-overview/tx-sprite.ts new file mode 100644 index 000000000..3145a8ea2 --- /dev/null +++ b/frontend/src/app/components/mempool-block-overview/tx-sprite.ts @@ -0,0 +1,211 @@ +import { FastVertexArray } from './fast-vertex-array' +import { InterpolatedAttribute, Attributes, OptionalAttributes, SpriteUpdateParams, Update } from './sprite-types' + +const attribKeys = ['a', 'b', 't', 'v'] +const updateKeys = ['x', 'y', 's', 'r', 'g', 'b', 'a'] + +export default class TxSprite { + vertexArray: FastVertexArray; + vertexPointer: number; + vertexData: number[]; + updateMap: Update; + attributes: Attributes; + tempAttributes: OptionalAttributes; + + static vertexSize: number = 30; + static vertexCount: number = 6; + static dataSize: number = (30*6); + + constructor(params: SpriteUpdateParams, vertexArray: FastVertexArray) { + const offsetTime = params.start + this.vertexArray = vertexArray + this.vertexData = Array(VI.length).fill(0) + this.updateMap = { + x: 0, y: 0, s: 0, r: 0, g: 0, b: 0, a: 0 + } + + this.attributes = { + x: { a: params.x, b: params.x, t: offsetTime, v: 0, d: 0 }, + y: { a: params.y, b: params.y, t: offsetTime, v: 0, d: 0 }, + s: { a: params.s, b: params.s, t: offsetTime, v: 0, d: 0 }, + r: { a: params.r, b: params.r, t: offsetTime, v: 0, d: 0 }, + g: { a: params.g, b: params.g, t: offsetTime, v: 0, d: 0 }, + b: { a: params.b, b: params.b, t: offsetTime, v: 0, d: 0 }, + a: { a: params.a, b: params.a, t: offsetTime, v: 0, d: 0 }, + } + + // Used to temporarily modify the sprite, so that the base view can be resumed later + this.tempAttributes = null + + this.vertexPointer = this.vertexArray.insert(this) + + this.compile() + } + + private interpolateAttributes (updateMap: Update, attributes: OptionalAttributes, offsetTime: DOMHighResTimeStamp, v: number, duration: number, minDuration: number, adjust: boolean): void { + for (const key of Object.keys(updateMap)) { + // for each non-null attribute: + if (updateMap[key] != null) { + // calculate current interpolated value, and set as 'from' + interpolateAttributeStart(attributes[key], offsetTime) + // update start time + attributes[key].t = offsetTime + + if (!adjust || (duration && attributes[key].d == 0)) { + attributes[key].v = v + attributes[key].d = duration + } else if (minDuration > attributes[key].d) { + // enforce minimum transition duration + attributes[key].v = 1 / minDuration + attributes[key].d = minDuration + } + // set 'to' to target value + attributes[key].b = updateMap[key] + } + } + } + + /* + params: + x, y, s: position & size of the sprite + r, g, b, a: color & opacity + start: performance.now() timestamp, when to start the transition + duration: of the tweening animation + adjust: if true, alter the target value of any conflicting transitions without changing the duration + minDuration: minimum remaining transition duration when adjust = true + temp: if true, this update is only temporary (can be reversed with 'resume') + */ + update (params: SpriteUpdateParams): void { + const offsetTime = params.start || performance.now() + const v = params.duration > 0 ? (1 / params.duration) : 0 + + updateKeys.forEach(key => { + this.updateMap[key] = params[key] + }) + + const isModified = !!this.tempAttributes + if (!params.temp) { + this.interpolateAttributes(this.updateMap, this.attributes, offsetTime, v, params.duration, params.minDuration, params.adjust) + } else { + if (!isModified) { // set up tempAttributes + this.tempAttributes = {} + for (const key of Object.keys(this.updateMap)) { + if (this.updateMap[key] != null) { + this.tempAttributes[key] = { ...this.attributes[key] } + } + } + } + this.interpolateAttributes(this.updateMap, this.tempAttributes, offsetTime, v, params.duration, params.minDuration, params.adjust) + } + + this.compile() + } + + // Transition back from modified state back to base attributes + resume (duration: number, start : DOMHighResTimeStamp = performance.now()): void { + // If not in modified state, there's nothing to do + if (!this.tempAttributes) return + + const offsetTime = start + const v = duration > 0 ? (1 / duration) : 0 + + for (const key of Object.keys(this.tempAttributes)) { + // If this base attribute is static (fixed or post-transition), transition smoothly back + if (this.attributes[key].v == 0 || (this.attributes[key].t + this.attributes[key].d) <= start) { + // calculate current interpolated value, and set as 'from' + interpolateAttributeStart(this.tempAttributes[key], offsetTime) + this.attributes[key].a = this.tempAttributes[key].a + this.attributes[key].t = offsetTime + this.attributes[key].v = v + this.attributes[key].d = duration + } + } + + this.tempAttributes = null + + this.compile() + } + + // Write current state into the graphics vertex array for rendering + compile (): void { + let attributes = this.attributes + if (this.tempAttributes) { + attributes = { + ...this.attributes, + ...this.tempAttributes + } + } + const size = attributes.s + + // update vertex data in place + // ugly, but avoids overhead of allocating large temporary arrays + const vertexStride = VI.length + 2 + for (let vertex = 0; vertex < 6; vertex++) { + this.vertexData[vertex * vertexStride] = vertexOffsetFactors[vertex][0] + this.vertexData[(vertex * vertexStride) + 1] = vertexOffsetFactors[vertex][1] + for (let step = 0; step < VI.length; step++) { + // components of each field in the vertex array are defined by an entry in VI: + // VI[i].a is the attribute, VI[i].f is the inner field, VI[i].offA and VI[i].offB are offset factors + this.vertexData[(vertex * vertexStride) + step + 2] = attributes[VI[step].a][VI[step].f] + } + } + + this.vertexArray.setData(this.vertexPointer, this.vertexData) + } + + moveVertexPointer (index: number): void { + this.vertexPointer = index + } + + destroy (): void { + this.vertexArray.remove(this.vertexPointer) + this.vertexPointer = null + } +} + +// expects 0 <= x <= 1 +function smootherstep(x: number): number { + let ix = 1 - x; + x = x * x + return x / (x + ix * ix) +} + +function interpolateAttributeStart(attribute: InterpolatedAttribute, start: DOMHighResTimeStamp): void { + if (attribute.v == 0 || (attribute.t + attribute.d) <= start) { + // transition finished, next transition starts from current end state + // (clamp to 1) + attribute.a = attribute.b + attribute.v = 0 + attribute.d = 0 + } else if (attribute.t > start) { + // transition not started + // (clamp to 0) + } else { + // transition in progress + // (interpolate) + let progress = (start - attribute.t) + let delta = smootherstep(progress / attribute.d) + attribute.a = attribute.a + (delta * (attribute.b - attribute.a)) + attribute.d = attribute.d - progress + attribute.v = 1 / attribute.d + } +} + +const vertexOffsetFactors = [ + [0,0], + [1,1], + [1,0], + [0,0], + [1,1], + [0,1] +] + +const VI = [] +updateKeys.forEach((attribute, aIndex) => { + attribKeys.forEach(field => { + VI.push({ + a: attribute, + f: field + }) + }) +}) diff --git a/frontend/src/app/components/mempool-block-overview/tx-view.ts b/frontend/src/app/components/mempool-block-overview/tx-view.ts new file mode 100644 index 000000000..464b8987e --- /dev/null +++ b/frontend/src/app/components/mempool-block-overview/tx-view.ts @@ -0,0 +1,144 @@ +import TxSprite from './tx-sprite' +import { FastVertexArray } from './fast-vertex-array' +import { TransactionStripped } from 'src/app/interfaces/websocket.interface'; +import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types' +import { feeLevels, mempoolFeeColors } from 'src/app/app.constants'; + +const hoverTransitionTime = 300 +const defaultHoverColor = hexToColor('1bd8f4') + +// convert from this class's update format to TxSprite's update format +function toSpriteUpdate(params : ViewUpdateParams): SpriteUpdateParams { + return { + start: (params.start || performance.now()) + (params.delay || 0), + duration: params.duration, + minDuration: params.minDuration, + ...params.display.position, + ...params.display.color, + adjust: params.adjust + } +} + +export default class TxView implements TransactionStripped { + txid: string; + fee: number; + vsize: number; + value: number; + feerate: number; + + initialised: boolean; + vertexArray: FastVertexArray; + hover: boolean; + sprite: TxSprite; + hoverColor: Color | void; + + screenPosition: Square; + gridPosition : Square | void; + + dirty: boolean; + + constructor (tx: TransactionStripped, vertexArray: FastVertexArray) { + this.txid = tx.txid + this.fee = tx.fee + this.vsize = tx.vsize + this.value = tx.value + this.feerate = tx.fee / tx.vsize + this.initialised = false + this.vertexArray = vertexArray + + this.hover = false + + this.screenPosition = { x: 0, y: 0, s: 0 } + + this.dirty = true + } + + destroy (): void { + if (this.sprite) { + this.sprite.destroy() + this.sprite = null + this.initialised = false + } + } + + applyGridPosition (position: Square): void { + if (!this.gridPosition) this.gridPosition = { x: 0, y: 0, s: 0 } + if (this.gridPosition.x != position.x || this.gridPosition.y != position.y || this.gridPosition.s != position.s) { + this.gridPosition.x = position.x + this.gridPosition.y = position.y + this.gridPosition.s = position.s + this.dirty = true + } + } + + /* + display: defines the final appearance of the sprite + position: { x, y, s } (coordinates & size) + color: { r, g, b, a} (color channels & alpha) + duration: of the tweening animation from the previous display state + start: performance.now() timestamp, when to start the transition + 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 + */ + update (params: ViewUpdateParams): void { + if (params.jitter) params.delay += (Math.random() * params.jitter) + + if (!this.initialised || !this.sprite) { + this.initialised = true + this.sprite = new TxSprite( + toSpriteUpdate(params), + this.vertexArray + ) + // apply any pending hover event + if (this.hover) { + this.sprite.update({ + ...this.hoverColor, + duration: hoverTransitionTime, + adjust: false, + temp: true + }) + } + } else { + this.sprite.update( + toSpriteUpdate(params) + ) + } + this.dirty = false + } + + // Temporarily override the tx color + setHover (hoverOn: boolean, color: Color | void = defaultHoverColor): void { + if (hoverOn) { + this.hover = true + this.hoverColor = color + + this.sprite.update({ + ...this.hoverColor, + duration: hoverTransitionTime, + adjust: false, + temp: true + }) + } else { + this.hover = false + this.hoverColor = null + if (this.sprite) this.sprite.resume(hoverTransitionTime) + } + this.dirty = false + } + + getColor (): Color { + let feeLevelIndex = feeLevels.slice().reverse().findIndex((feeLvl) => (this.feerate || 1) >= feeLvl); + feeLevelIndex = feeLevelIndex >= 0 ? feeLevels.length - feeLevelIndex : feeLevelIndex; + return hexToColor(mempoolFeeColors[feeLevelIndex - 1] || mempoolFeeColors[mempoolFeeColors.length - 1]) + } +} + +function hexToColor (hex: string): Color { + return { + r: parseInt(hex.slice(0,2), 16) / 255, + g: parseInt(hex.slice(2,4), 16) / 255, + b: parseInt(hex.slice(4,6), 16) / 255, + a: 1 + } +} 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 2701f9ece..da1e91f1b 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.html +++ b/frontend/src/app/components/mempool-block/mempool-block.component.html @@ -10,38 +10,69 @@
- +
- - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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 -
-
-
-
-
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
+
- + +
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 75147f5e3..e20c0b67d 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.ts +++ b/frontend/src/app/components/mempool-block/mempool-block.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/ import { StateService } from 'src/app/services/state.service'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { switchMap, map, tap, filter } from 'rxjs/operators'; -import { MempoolBlock } from 'src/app/interfaces/websocket.interface'; +import { MempoolBlock, TransactionStripped } from 'src/app/interfaces/websocket.interface'; import { Observable, BehaviorSubject } from 'rxjs'; import { SeoService } from 'src/app/services/seo.service'; import { WebsocketService } from 'src/app/services/websocket.service'; @@ -18,13 +18,17 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { mempoolBlockIndex: number; mempoolBlock$: Observable; ordinal$: BehaviorSubject = new BehaviorSubject(''); + previewTx: TransactionStripped | void; + webGlEnabled: boolean; constructor( private route: ActivatedRoute, public stateService: StateService, private seoService: SeoService, private websocketService: WebsocketService, - ) { } + ) { + this.webGlEnabled = detectWebGL(); + } ngOnInit(): void { this.websocketService.want(['blocks', 'mempool-blocks']); @@ -74,5 +78,15 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { } else { return $localize`:@@mempool-block.block.no:Mempool block ${this.mempoolBlockIndex + 1}:INTERPOLATION:`; } - } + } + + setTxPreview(event: TransactionStripped | void): void { + this.previewTx = event + } +} + +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/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index a080f6df0..5d17ae43a 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -14,6 +14,7 @@ import { LbtcPegsGraphComponent } from '../components/lbtc-pegs-graph/lbtc-pegs- import { GraphsComponent } from '../components/graphs/graphs.component'; import { StatisticsComponent } from '../components/statistics/statistics.component'; import { MempoolBlockComponent } from '../components/mempool-block/mempool-block.component'; +import { MempoolBlockOverviewComponent } from '../components/mempool-block-overview/mempool-block-overview.component'; import { PoolRankingComponent } from '../components/pool-ranking/pool-ranking.component'; import { PoolComponent } from '../components/pool/pool.component'; import { TelevisionComponent } from '../components/television/television.component'; @@ -40,6 +41,7 @@ import { CommonModule } from '@angular/common'; BlockFeeRatesGraphComponent, BlockSizesWeightsGraphComponent, FeeDistributionGraphComponent, + MempoolBlockOverviewComponent, IncomingTransactionsGraphComponent, MempoolGraphComponent, LbtcPegsGraphComponent, diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 1eb0cc92d..d7f0addea 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -25,6 +25,7 @@ export interface WebsocketResponse { 'track-tx'?: string; 'track-address'?: string; 'track-asset'?: string; + 'track-mempool-block'?: number; 'watch-mempool'?: boolean; 'track-bisq-market'?: string; } @@ -44,6 +45,16 @@ export interface MempoolBlock { index: number; } +export interface MempoolBlockWithTransactions extends MempoolBlock { + transactionIds: string[]; + transactions: TransactionStripped[]; +} + +export interface MempoolBlockDelta { + added: TransactionStripped[], + removed: string[], +} + export interface MempoolInfo { loaded: boolean; // (boolean) True if the mempool is fully loaded size: number; // (numeric) Current tx count diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 42163a312..0397b53ee 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; import { Transaction } from '../interfaces/electrs.interface'; -import { IBackendInfo, MempoolBlock, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; +import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; @@ -80,6 +80,8 @@ export class StateService { bsqPrice$ = new ReplaySubject(1); mempoolInfo$ = new ReplaySubject(1); mempoolBlocks$ = new ReplaySubject(1); + mempoolBlock$ = new Subject(); + mempoolBlockDelta$ = new Subject(); txReplaced$ = new Subject(); utxoSpent$ = new Subject(); difficultyAdjustment$ = new ReplaySubject(1); diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 2f398fe17..9507ea9d5 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -27,6 +27,8 @@ export class WebsocketService { private lastWant: string | null = null; private isTrackingTx = false; private trackingTxId: string; + private isTrackingMempoolBlock = false; + private trackingMempoolBlock: number; private latestGitCommit = ''; private onlineCheckTimeout: number; private onlineCheckTimeoutTwo: number; @@ -102,6 +104,9 @@ export class WebsocketService { if (this.isTrackingTx) { this.startMultiTrackTransaction(this.trackingTxId); } + if (this.isTrackingMempoolBlock) { + this.startTrackMempoolBlock(this.trackingMempoolBlock); + } this.stateService.connectionState$.next(2); } @@ -157,6 +162,17 @@ export class WebsocketService { this.websocketSubject.next({ 'track-asset': 'stop' }); } + startTrackMempoolBlock(block: number) { + this.websocketSubject.next({ 'track-mempool-block': block }); + this.isTrackingMempoolBlock = true + this.trackingMempoolBlock = block + } + + stopTrackMempoolBlock() { + this.websocketSubject.next({ 'track-mempool-block': -1 }); + this.isTrackingMempoolBlock = false + } + startTrackBisqMarket(market: string) { this.websocketSubject.next({ 'track-bisq-market': market }); } @@ -293,6 +309,16 @@ export class WebsocketService { }); } + if (response['projected-mempool-block']) { + if (response['projected-mempool-block'].index == this.trackingMempoolBlock) { + if (response['projected-mempool-block'].block) { + this.stateService.mempoolBlock$.next(response['projected-mempool-block'].block); + } else if (response['projected-mempool-block'].delta) { + this.stateService.mempoolBlockDelta$.next(response['projected-mempool-block'].delta); + } + } + } + if (response['live-2h-chart']) { this.stateService.live2Chart$.next(response['live-2h-chart']); } diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 318cd03d5..a44d2c30d 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -243,6 +243,10 @@ body { } } +.table-fixed { + table-layout: fixed; +} + .close { color: #fff; }