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