Merge pull request #1774 from mononaut/projected-block-overview
Feature: Projected block visualization
This commit is contained in:
		
						commit
						372d1d6042
					
				@ -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)),
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -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 {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								contributors/mononaut.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/mononaut.txt
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
@ -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)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,6 @@
 | 
			
		||||
<div class="mempool-block-overview">
 | 
			
		||||
  <canvas class="block-overview" #blockCanvas></canvas>
 | 
			
		||||
  <div class="loader-wrapper" [class.hidden]="!(isLoading$ | async)">
 | 
			
		||||
    <div class="spinner-border ml-3 loading" role="status"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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<TransactionStripped | void>()
 | 
			
		||||
 | 
			
		||||
  @ViewChild('blockCanvas')
 | 
			
		||||
  canvas: ElementRef<HTMLCanvasElement>;
 | 
			
		||||
 | 
			
		||||
  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<boolean>(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;
 | 
			
		||||
}
 | 
			
		||||
`
 | 
			
		||||
@ -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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										211
									
								
								frontend/src/app/components/mempool-block-overview/tx-sprite.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										211
									
								
								frontend/src/app/components/mempool-block-overview/tx-sprite.ts
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										144
									
								
								frontend/src/app/components/mempool-block-overview/tx-view.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								frontend/src/app/components/mempool-block-overview/tx-view.ts
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -10,38 +10,69 @@
 | 
			
		||||
  <div class="box">
 | 
			
		||||
    <div class="row">
 | 
			
		||||
      <div class="col-md">
 | 
			
		||||
        <table class="table table-borderless table-striped">
 | 
			
		||||
        <table class="table table-borderless table-striped table-fixed">
 | 
			
		||||
          <tbody>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td i18n="mempool-block.median-fee">Median fee</td>
 | 
			
		||||
              <td>~{{ mempoolBlock.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="mempoolBlock.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td i18n="mempool-block.fee-span">Fee span</td>
 | 
			
		||||
              <td><span class="yellow-color">{{ mempoolBlock.feeRange[0] | number:'1.0-0' }} - {{ mempoolBlock.feeRange[mempoolBlock.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td i18n="block.total-fees|Total fees in a block">Total fees</td>
 | 
			
		||||
              <td><app-amount [satoshis]="mempoolBlock.totalFees" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> <span class="fiat"><app-fiat [value]="mempoolBlock.totalFees" digitsInfo="1.0-0"></app-fiat></span></td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td i18n="mempool-block.transactions">Transactions</td>
 | 
			
		||||
              <td>{{ mempoolBlock.nTx }}</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td i18n="mempool-block.size">Size</td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <div class="progress">
 | 
			
		||||
                  <div class="progress-bar progress-mempool {{ (network$ | async) }}" role="progressbar" [ngStyle]="{'width': (mempoolBlock.blockVSize / stateService.blockVSize) * 100 + '%' }"></div>
 | 
			
		||||
                  <div class="progress-text" [innerHTML]="mempoolBlock.blockSize | bytes: 2"></div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <ng-container *ngIf="!previewTx">
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="mempool-block.median-fee">Median fee</td>
 | 
			
		||||
                <td>~{{ mempoolBlock.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="mempoolBlock.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="mempool-block.fee-span">Fee span</td>
 | 
			
		||||
                <td><span class="yellow-color">{{ mempoolBlock.feeRange[0] | number:'1.0-0' }} - {{ mempoolBlock.feeRange[mempoolBlock.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="block.total-fees|Total fees in a block">Total fees</td>
 | 
			
		||||
                <td><app-amount [satoshis]="mempoolBlock.totalFees" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> <span class="fiat"><app-fiat [value]="mempoolBlock.totalFees" digitsInfo="1.0-0"></app-fiat></span></td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="mempool-block.transactions">Transactions</td>
 | 
			
		||||
                <td>{{ mempoolBlock.nTx }}</td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="mempool-block.size">Size</td>
 | 
			
		||||
                <td>
 | 
			
		||||
                  <div class="progress">
 | 
			
		||||
                    <div class="progress-bar progress-mempool {{ (network$ | async) }}" role="progressbar" [ngStyle]="{'width': (mempoolBlock.blockVSize / stateService.blockVSize) * 100 + '%' }"></div>
 | 
			
		||||
                    <div class="progress-text" [innerHTML]="mempoolBlock.blockSize | bytes: 2"></div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </ng-container>
 | 
			
		||||
 | 
			
		||||
            <ng-container *ngIf="previewTx">
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="shared.transaction">Transaction</td>
 | 
			
		||||
                <td>
 | 
			
		||||
                  <a [routerLink]="['/tx/' | relativeUrl, previewTx.txid]">{{ previewTx.txid | shortenString : 16}}</a>
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td class="td-width" i18n="transaction.value|Transaction value">Value</td>
 | 
			
		||||
                <td><app-amount [satoshis]="previewTx.value"></app-amount></td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
 | 
			
		||||
                <td>{{ previewTx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> <span class="fiat"><app-fiat [value]="previewTx.fee"></app-fiat></span></td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
 | 
			
		||||
                <td>
 | 
			
		||||
                  {{ (previewTx.fee / previewTx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
 | 
			
		||||
                <td [innerHTML]="'‎' + (previewTx.vsize | vbytes: 2)"></td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              </ng-container>
 | 
			
		||||
          </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
        <app-fee-distribution-graph *ngIf="webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col-md chart-container">
 | 
			
		||||
        <app-fee-distribution-graph [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph>
 | 
			
		||||
        <app-mempool-block-overview *ngIf="webGlEnabled" [index]="mempoolBlockIndex" (txPreviewEvent)="setTxPreview($event)"></app-mempool-block-overview>
 | 
			
		||||
        <app-fee-distribution-graph *ngIf="!webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
@ -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<MempoolBlock>;
 | 
			
		||||
  ordinal$: BehaviorSubject<string> = 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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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<number>(1);
 | 
			
		||||
  mempoolInfo$ = new ReplaySubject<MempoolInfo>(1);
 | 
			
		||||
  mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1);
 | 
			
		||||
  mempoolBlock$ = new Subject<MempoolBlockWithTransactions>();
 | 
			
		||||
  mempoolBlockDelta$ = new Subject<MempoolBlockDelta>();
 | 
			
		||||
  txReplaced$ = new Subject<ReplacedTransaction>();
 | 
			
		||||
  utxoSpent$ = new Subject<object>();
 | 
			
		||||
  difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1);
 | 
			
		||||
 | 
			
		||||
@ -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']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -243,6 +243,10 @@ body {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.table-fixed {
 | 
			
		||||
  table-layout: fixed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.close {
 | 
			
		||||
  color: #fff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user