Merge branch 'master' into nymkappa/feature/remove-hide-button
This commit is contained in:
commit
eb169cf58b
@ -13,6 +13,7 @@
|
|||||||
"INITIAL_BLOCKS_AMOUNT": 8,
|
"INITIAL_BLOCKS_AMOUNT": 8,
|
||||||
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||||
"INDEXING_BLOCKS_AMOUNT": 11000,
|
"INDEXING_BLOCKS_AMOUNT": 11000,
|
||||||
|
"BLOCKS_SUMMARIES_INDEXING": false,
|
||||||
"PRICE_FEED_UPDATE_INTERVAL": 600,
|
"PRICE_FEED_UPDATE_INTERVAL": 600,
|
||||||
"USE_SECOND_NODE_FOR_MINFEE": false,
|
"USE_SECOND_NODE_FOR_MINFEE": false,
|
||||||
"EXTERNAL_ASSETS": [],
|
"EXTERNAL_ASSETS": [],
|
||||||
|
@ -73,6 +73,14 @@ export namespace IBitcoinApi {
|
|||||||
time: number; // (numeric) Same as blocktime
|
time: number; // (numeric) Same as blocktime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VerboseBlock extends Block {
|
||||||
|
tx: VerboseTransaction[]; // The transactions in the format of the getrawtransaction RPC. Different from verbosity = 1 "tx" result
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerboseTransaction extends Transaction {
|
||||||
|
fee?: number; // (numeric) The transaction fee in BTC, omitted if block undo data is not available
|
||||||
|
}
|
||||||
|
|
||||||
export interface Vin {
|
export interface Vin {
|
||||||
txid?: string; // (string) The transaction id
|
txid?: string; // (string) The transaction id
|
||||||
vout?: number; // (string)
|
vout?: number; // (string)
|
||||||
|
@ -2,11 +2,12 @@ import config from '../config';
|
|||||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import memPool from './mempool';
|
import memPool from './mempool';
|
||||||
import { BlockExtended, PoolTag, TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
import { BlockExtended, BlockSummary, PoolTag, TransactionExtended, TransactionStripped, TransactionMinerInfo } from '../mempool.interfaces';
|
||||||
import { Common } from './common';
|
import { Common } from './common';
|
||||||
import diskCache from './disk-cache';
|
import diskCache from './disk-cache';
|
||||||
import transactionUtils from './transaction-utils';
|
import transactionUtils from './transaction-utils';
|
||||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||||
|
import { IBitcoinApi } from './bitcoin/bitcoin-api.interface';
|
||||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||||
import poolsRepository from '../repositories/PoolsRepository';
|
import poolsRepository from '../repositories/PoolsRepository';
|
||||||
import blocksRepository from '../repositories/BlocksRepository';
|
import blocksRepository from '../repositories/BlocksRepository';
|
||||||
@ -19,9 +20,11 @@ import indexer from '../indexer';
|
|||||||
import fiatConversion from './fiat-conversion';
|
import fiatConversion from './fiat-conversion';
|
||||||
import RatesRepository from '../repositories/RatesRepository';
|
import RatesRepository from '../repositories/RatesRepository';
|
||||||
import poolsParser from './pools-parser';
|
import poolsParser from './pools-parser';
|
||||||
|
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||||
|
|
||||||
class Blocks {
|
class Blocks {
|
||||||
private blocks: BlockExtended[] = [];
|
private blocks: BlockExtended[] = [];
|
||||||
|
private blockSummaries: BlockSummary[] = [];
|
||||||
private currentBlockHeight = 0;
|
private currentBlockHeight = 0;
|
||||||
private currentDifficulty = 0;
|
private currentDifficulty = 0;
|
||||||
private lastDifficultyAdjustmentTime = 0;
|
private lastDifficultyAdjustmentTime = 0;
|
||||||
@ -38,6 +41,14 @@ class Blocks {
|
|||||||
this.blocks = blocks;
|
this.blocks = blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getBlockSummaries(): BlockSummary[] {
|
||||||
|
return this.blockSummaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setBlockSummaries(blockSummaries: BlockSummary[]) {
|
||||||
|
this.blockSummaries = blockSummaries;
|
||||||
|
}
|
||||||
|
|
||||||
public setNewBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void) {
|
public setNewBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void) {
|
||||||
this.newBlockCallbacks.push(fn);
|
this.newBlockCallbacks.push(fn);
|
||||||
}
|
}
|
||||||
@ -106,6 +117,27 @@ class Blocks {
|
|||||||
return transactions;
|
return transactions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a block summary (list of stripped transactions)
|
||||||
|
* @param block
|
||||||
|
* @returns BlockSummary
|
||||||
|
*/
|
||||||
|
private summarizeBlock(block: IBitcoinApi.VerboseBlock): BlockSummary {
|
||||||
|
const stripped = block.tx.map((tx) => {
|
||||||
|
return {
|
||||||
|
txid: tx.txid,
|
||||||
|
vsize: tx.vsize,
|
||||||
|
fee: tx.fee ? Math.round(tx.fee * 100000000) : 0,
|
||||||
|
value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0) * 100000000)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: block.hash,
|
||||||
|
transactions: stripped
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a block with additional data (reward, coinbase, fees...)
|
* Return a block with additional data (reward, coinbase, fees...)
|
||||||
* @param block
|
* @param block
|
||||||
@ -211,13 +243,68 @@ class Blocks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [INDEXING] Index all blocks summaries for the block txs visualization
|
||||||
|
*/
|
||||||
|
public async $generateBlocksSummariesDatabase() {
|
||||||
|
if (Common.blocksSummariesIndexingEnabled() === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all indexed block hash
|
||||||
|
const indexedBlocks = await blocksRepository.$getIndexedBlocks();
|
||||||
|
const indexedBlockSummariesHashesArray = await BlocksSummariesRepository.$getIndexedSummariesId();
|
||||||
|
|
||||||
|
const indexedBlockSummariesHashes = {}; // Use a map for faster seek during the indexing loop
|
||||||
|
for (const hash of indexedBlockSummariesHashesArray) {
|
||||||
|
indexedBlockSummariesHashes[hash] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
let newlyIndexed = 0;
|
||||||
|
let totalIndexed = indexedBlockSummariesHashesArray.length;
|
||||||
|
let indexedThisRun = 0;
|
||||||
|
let timer = new Date().getTime() / 1000;
|
||||||
|
const startedAt = new Date().getTime() / 1000;
|
||||||
|
|
||||||
|
for (const block of indexedBlocks) {
|
||||||
|
if (indexedBlockSummariesHashes[block.hash] === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||||
|
if (elapsedSeconds > 5) {
|
||||||
|
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
||||||
|
const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds);
|
||||||
|
const progress = Math.round(totalIndexed / indexedBlocks.length * 10000) / 100;
|
||||||
|
const timeLeft = Math.round((indexedBlocks.length - totalIndexed) / blockPerSeconds);
|
||||||
|
logger.debug(`Indexing block summary for #${block.height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexedBlocks.length} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`);
|
||||||
|
timer = new Date().getTime() / 1000;
|
||||||
|
indexedThisRun = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
indexedThisRun++;
|
||||||
|
totalIndexed++;
|
||||||
|
newlyIndexed++;
|
||||||
|
}
|
||||||
|
logger.notice(`Blocks summaries indexing completed: indexed ${newlyIndexed} blocks`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Blocks summaries indexing failed. Reason: ${(e instanceof Error ? e.message : e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [INDEXING] Index all blocks metadata for the mining dashboard
|
* [INDEXING] Index all blocks metadata for the mining dashboard
|
||||||
*/
|
*/
|
||||||
public async $generateBlockDatabase() {
|
public async $generateBlockDatabase(): Promise<boolean> {
|
||||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||||
if (blockchainInfo.blocks !== blockchainInfo.headers) { // Wait for node to sync
|
if (blockchainInfo.blocks !== blockchainInfo.headers) { // Wait for node to sync
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -261,7 +348,7 @@ class Blocks {
|
|||||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||||
if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) {
|
if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) {
|
||||||
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
||||||
const blockPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds));
|
const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds);
|
||||||
const progress = Math.round(totalIndexed / indexingBlockAmount * 10000) / 100;
|
const progress = Math.round(totalIndexed / indexingBlockAmount * 10000) / 100;
|
||||||
const timeLeft = Math.round((indexingBlockAmount - totalIndexed) / blockPerSeconds);
|
const timeLeft = Math.round((indexingBlockAmount - totalIndexed) / blockPerSeconds);
|
||||||
logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`);
|
logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`);
|
||||||
@ -285,13 +372,16 @@ class Blocks {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.err('Block indexing failed. Trying again later. Reason: ' + (e instanceof Error ? e.message : e));
|
logger.err('Block indexing failed. Trying again later. Reason: ' + (e instanceof Error ? e.message : e));
|
||||||
loadingIndicators.setProgress('block-indexing', 100);
|
loadingIndicators.setProgress('block-indexing', 100);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chainValid = await BlocksRepository.$validateChain();
|
const chainValid = await BlocksRepository.$validateChain();
|
||||||
if (!chainValid) {
|
if (!chainValid) {
|
||||||
indexer.reindex();
|
indexer.reindex();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $updateBlocks() {
|
public async $updateBlocks() {
|
||||||
@ -341,10 +431,12 @@ class Blocks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight);
|
const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight);
|
||||||
const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash));
|
const verboseBlock = await bitcoinClient.getBlock(blockHash, 2);
|
||||||
|
const block = BitcoinApi.convertBlock(verboseBlock);
|
||||||
const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash);
|
const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash);
|
||||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, false);
|
const transactions = await this.$getTransactionsExtended(blockHash, block.height, false);
|
||||||
const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions);
|
const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions);
|
||||||
|
const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock);
|
||||||
|
|
||||||
if (Common.indexingEnabled()) {
|
if (Common.indexingEnabled()) {
|
||||||
if (!fastForwarded) {
|
if (!fastForwarded) {
|
||||||
@ -354,11 +446,19 @@ class Blocks {
|
|||||||
// We assume there won't be a reorg with more than 10 block depth
|
// We assume there won't be a reorg with more than 10 block depth
|
||||||
await BlocksRepository.$deleteBlocksFrom(lastBlock['height'] - 10);
|
await BlocksRepository.$deleteBlocksFrom(lastBlock['height'] - 10);
|
||||||
await HashratesRepository.$deleteLastEntries();
|
await HashratesRepository.$deleteLastEntries();
|
||||||
|
await BlocksSummariesRepository.$deleteBlocksFrom(lastBlock['height'] - 10);
|
||||||
for (let i = 10; i >= 0; --i) {
|
for (let i = 10; i >= 0; --i) {
|
||||||
await this.$indexBlock(lastBlock['height'] - i);
|
const newBlock = await this.$indexBlock(lastBlock['height'] - i);
|
||||||
|
await this.$getStrippedBlockTransactions(newBlock.id, true, true);
|
||||||
}
|
}
|
||||||
|
logger.info(`Re-indexed 10 blocks and summaries`);
|
||||||
}
|
}
|
||||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||||
|
|
||||||
|
// Save blocks summary for visualization if it's enabled
|
||||||
|
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||||
|
await this.$getStrippedBlockTransactions(blockExtended.id, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fiatConversion.ratesInitialized === true && config.DATABASE.ENABLED === true) {
|
if (fiatConversion.ratesInitialized === true && config.DATABASE.ENABLED === true) {
|
||||||
@ -375,6 +475,10 @@ class Blocks {
|
|||||||
if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
|
if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
|
||||||
this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
|
this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
|
||||||
}
|
}
|
||||||
|
this.blockSummaries.push(blockSummary);
|
||||||
|
if (this.blockSummaries.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
|
||||||
|
this.blockSummaries = this.blockSummaries.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.newBlockCallbacks.length) {
|
if (this.newBlockCallbacks.length) {
|
||||||
this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions));
|
this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions));
|
||||||
@ -440,6 +544,37 @@ class Blocks {
|
|||||||
return blockExtended;
|
return blockExtended;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async $getStrippedBlockTransactions(hash: string, skipMemoryCache: boolean = false,
|
||||||
|
skipDBLookup: boolean = false): Promise<TransactionStripped[]>
|
||||||
|
{
|
||||||
|
if (skipMemoryCache === false) {
|
||||||
|
// Check the memory cache
|
||||||
|
const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash);
|
||||||
|
if (cachedSummary) {
|
||||||
|
return cachedSummary.transactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's indexed in db
|
||||||
|
if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) {
|
||||||
|
const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash);
|
||||||
|
if (indexedSummary !== undefined) {
|
||||||
|
return indexedSummary.transactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Core RPC
|
||||||
|
const block = await bitcoinClient.getBlock(hash, 2);
|
||||||
|
const summary = this.summarizeBlock(block);
|
||||||
|
|
||||||
|
// Index the response if needed
|
||||||
|
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||||
|
await BlocksSummariesRepository.$saveSummary(block.height, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary.transactions;
|
||||||
|
}
|
||||||
|
|
||||||
public async $getBlocks(fromHeight?: number, limit: number = 15): Promise<BlockExtended[]> {
|
public async $getBlocks(fromHeight?: number, limit: number = 15): Promise<BlockExtended[]> {
|
||||||
try {
|
try {
|
||||||
let currentHeight = fromHeight !== undefined ? fromHeight : this.getCurrentBlockHeight();
|
let currentHeight = fromHeight !== undefined ? fromHeight : this.getCurrentBlockHeight();
|
||||||
|
@ -177,4 +177,11 @@ export class Common {
|
|||||||
config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== 0
|
config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static blocksSummariesIndexingEnabled(): boolean {
|
||||||
|
return (
|
||||||
|
Common.indexingEnabled() &&
|
||||||
|
config.MEMPOOL.BLOCKS_SUMMARIES_INDEXING === true
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import logger from '../logger';
|
|||||||
import { Common } from './common';
|
import { Common } from './common';
|
||||||
|
|
||||||
class DatabaseMigration {
|
class DatabaseMigration {
|
||||||
private static currentVersion = 19;
|
private static currentVersion = 20;
|
||||||
private queryTimeout = 120000;
|
private queryTimeout = 120000;
|
||||||
private statisticsAddedIndexed = false;
|
private statisticsAddedIndexed = false;
|
||||||
private uniqueLogs: string[] = [];
|
private uniqueLogs: string[] = [];
|
||||||
@ -217,6 +217,10 @@ class DatabaseMigration {
|
|||||||
if (databaseSchemaVersion < 19) {
|
if (databaseSchemaVersion < 19) {
|
||||||
await this.$executeQuery(this.getCreateRatesTableQuery(), await this.$checkIfTableExists('rates'));
|
await this.$executeQuery(this.getCreateRatesTableQuery(), await this.$checkIfTableExists('rates'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (databaseSchemaVersion < 20 && isBitcoin === true) {
|
||||||
|
await this.$executeQuery(this.getCreateBlocksSummariesTableQuery(), await this.$checkIfTableExists('blocks_summaries'));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@ -512,6 +516,16 @@ class DatabaseMigration {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCreateBlocksSummariesTableQuery(): string {
|
||||||
|
return `CREATE TABLE IF NOT EXISTS blocks_summaries (
|
||||||
|
height int(10) unsigned NOT NULL,
|
||||||
|
id varchar(65) NOT NULL,
|
||||||
|
transactions JSON NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
INDEX (height)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||||
|
}
|
||||||
|
|
||||||
public async $truncateIndexedData(tables: string[]) {
|
public async $truncateIndexedData(tables: string[]) {
|
||||||
const allowedTables = ['blocks', 'hashrates'];
|
const allowedTables = ['blocks', 'hashrates'];
|
||||||
|
|
||||||
|
@ -43,6 +43,7 @@ class DiskCache {
|
|||||||
await fsPromises.writeFile(DiskCache.FILE_NAME, JSON.stringify({
|
await fsPromises.writeFile(DiskCache.FILE_NAME, JSON.stringify({
|
||||||
cacheSchemaVersion: this.cacheSchemaVersion,
|
cacheSchemaVersion: this.cacheSchemaVersion,
|
||||||
blocks: blocks.getBlocks(),
|
blocks: blocks.getBlocks(),
|
||||||
|
blockSummaries: blocks.getBlockSummaries(),
|
||||||
mempool: {},
|
mempool: {},
|
||||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||||
}), {flag: 'w'});
|
}), {flag: 'w'});
|
||||||
@ -109,6 +110,7 @@ class DiskCache {
|
|||||||
|
|
||||||
memPool.setMempool(data.mempool);
|
memPool.setMempool(data.mempool);
|
||||||
blocks.setBlocks(data.blocks);
|
blocks.setBlocks(data.blocks);
|
||||||
|
blocks.setBlockSummaries(data.blockSummaries || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ interface IConfig {
|
|||||||
INITIAL_BLOCKS_AMOUNT: number;
|
INITIAL_BLOCKS_AMOUNT: number;
|
||||||
MEMPOOL_BLOCKS_AMOUNT: number;
|
MEMPOOL_BLOCKS_AMOUNT: number;
|
||||||
INDEXING_BLOCKS_AMOUNT: number;
|
INDEXING_BLOCKS_AMOUNT: number;
|
||||||
|
BLOCKS_SUMMARIES_INDEXING: boolean;
|
||||||
PRICE_FEED_UPDATE_INTERVAL: number;
|
PRICE_FEED_UPDATE_INTERVAL: number;
|
||||||
USE_SECOND_NODE_FOR_MINFEE: boolean;
|
USE_SECOND_NODE_FOR_MINFEE: boolean;
|
||||||
EXTERNAL_ASSETS: string[];
|
EXTERNAL_ASSETS: string[];
|
||||||
@ -104,6 +105,7 @@ const defaults: IConfig = {
|
|||||||
'INITIAL_BLOCKS_AMOUNT': 8,
|
'INITIAL_BLOCKS_AMOUNT': 8,
|
||||||
'MEMPOOL_BLOCKS_AMOUNT': 8,
|
'MEMPOOL_BLOCKS_AMOUNT': 8,
|
||||||
'INDEXING_BLOCKS_AMOUNT': 11000, // 0 = disable indexing, -1 = index all blocks
|
'INDEXING_BLOCKS_AMOUNT': 11000, // 0 = disable indexing, -1 = index all blocks
|
||||||
|
'BLOCKS_SUMMARIES_INDEXING': false,
|
||||||
'PRICE_FEED_UPDATE_INTERVAL': 600,
|
'PRICE_FEED_UPDATE_INTERVAL': 600,
|
||||||
'USE_SECOND_NODE_FOR_MINFEE': false,
|
'USE_SECOND_NODE_FOR_MINFEE': false,
|
||||||
'EXTERNAL_ASSETS': [],
|
'EXTERNAL_ASSETS': [],
|
||||||
|
@ -314,7 +314,8 @@ class Server {
|
|||||||
this.app
|
this.app
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks', routes.getBlocks.bind(routes))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks', routes.getBlocks.bind(routes))
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', routes.getBlocks.bind(routes))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', routes.getBlocks.bind(routes))
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', routes.getBlock);
|
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', routes.getBlock)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', routes.getStrippedBlockTransactions);
|
||||||
|
|
||||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||||
this.app
|
this.app
|
||||||
|
@ -29,10 +29,17 @@ class Indexer {
|
|||||||
this.indexerRunning = true;
|
this.indexerRunning = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await blocks.$generateBlockDatabase();
|
const chainValid = await blocks.$generateBlockDatabase();
|
||||||
|
if (chainValid === false) {
|
||||||
|
// Chain of block hash was invalid, so we need to reindex. Stop here and continue at the next iteration
|
||||||
|
this.indexerRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.$resetHashratesIndexingState();
|
await this.$resetHashratesIndexingState();
|
||||||
await mining.$generateNetworkHashrateHistory();
|
await mining.$generateNetworkHashrateHistory();
|
||||||
await mining.$generatePoolHashrateHistory();
|
await mining.$generatePoolHashrateHistory();
|
||||||
|
await blocks.$generateBlocksSummariesDatabase();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.reindex();
|
this.reindex();
|
||||||
logger.err(`Indexer failed, trying again later. Reason: ` + (e instanceof Error ? e.message : e));
|
logger.err(`Indexer failed, trying again later. Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
|
@ -106,6 +106,11 @@ export interface BlockExtended extends IEsploraApi.Block {
|
|||||||
extras: BlockExtension;
|
extras: BlockExtension;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BlockSummary {
|
||||||
|
id: string;
|
||||||
|
transactions: TransactionStripped[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TransactionMinerInfo {
|
export interface TransactionMinerInfo {
|
||||||
vin: VinStrippedToScriptsig[];
|
vin: VinStrippedToScriptsig[];
|
||||||
vout: VoutStrippedToScriptPubkey[];
|
vout: VoutStrippedToScriptPubkey[];
|
||||||
|
@ -6,6 +6,7 @@ import { prepareBlock } from '../utils/blocks-utils';
|
|||||||
import PoolsRepository from './PoolsRepository';
|
import PoolsRepository from './PoolsRepository';
|
||||||
import HashratesRepository from './HashratesRepository';
|
import HashratesRepository from './HashratesRepository';
|
||||||
import { escape } from 'mysql2';
|
import { escape } from 'mysql2';
|
||||||
|
import BlocksSummariesRepository from './BlocksSummariesRepository';
|
||||||
|
|
||||||
class BlocksRepository {
|
class BlocksRepository {
|
||||||
/**
|
/**
|
||||||
@ -495,6 +496,7 @@ class BlocksRepository {
|
|||||||
if (blocks[idx].previous_block_hash !== blocks[idx - 1].hash) {
|
if (blocks[idx].previous_block_hash !== blocks[idx - 1].hash) {
|
||||||
logger.warn(`Chain divergence detected at block ${blocks[idx - 1].height}, re-indexing newer blocks and hashrates`);
|
logger.warn(`Chain divergence detected at block ${blocks[idx - 1].height}, re-indexing newer blocks and hashrates`);
|
||||||
await this.$deleteBlocksFrom(blocks[idx - 1].height);
|
await this.$deleteBlocksFrom(blocks[idx - 1].height);
|
||||||
|
await BlocksSummariesRepository.$deleteBlocksFrom(blocks[idx - 1].height);
|
||||||
await HashratesRepository.$deleteHashratesFromTimestamp(blocks[idx - 1].timestamp - 604800);
|
await HashratesRepository.$deleteHashratesFromTimestamp(blocks[idx - 1].timestamp - 604800);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -652,6 +654,19 @@ class BlocksRepository {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of blocks that have been indexed
|
||||||
|
*/
|
||||||
|
public async $getIndexedBlocks(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const [rows]: any = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new BlocksRepository();
|
export default new BlocksRepository();
|
||||||
|
59
backend/src/repositories/BlocksSummariesRepository.ts
Normal file
59
backend/src/repositories/BlocksSummariesRepository.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import DB from '../database';
|
||||||
|
import logger from '../logger';
|
||||||
|
import { BlockSummary } from '../mempool.interfaces';
|
||||||
|
|
||||||
|
class BlocksSummariesRepository {
|
||||||
|
public async $getByBlockId(id: string): Promise<BlockSummary | undefined> {
|
||||||
|
try {
|
||||||
|
const [summary]: any[] = await DB.query(`SELECT * from blocks_summaries WHERE id = ?`, [id]);
|
||||||
|
if (summary.length > 0) {
|
||||||
|
summary[0].transactions = JSON.parse(summary[0].transactions);
|
||||||
|
return summary[0];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot get block summary for block id ${id}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $saveSummary(height: number, summary: BlockSummary) {
|
||||||
|
try {
|
||||||
|
await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?)`, [height, summary.id, JSON.stringify(summary.transactions)]);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||||
|
logger.debug(`Cannot save block summary for ${summary.id} because it has already been indexed, ignoring`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`Cannot save block summary for ${summary.id}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getIndexedSummariesId(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries`);
|
||||||
|
return rows.map(row => row.id);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot get block summaries id list. Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete blocks from the database from blockHeight
|
||||||
|
*/
|
||||||
|
public async $deleteBlocksFrom(blockHeight: number) {
|
||||||
|
logger.info(`Delete newer blocks summary from height ${blockHeight} from the database`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await DB.query(`DELETE FROM blocks_summaries where height >= ${blockHeight}`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('Cannot delete indexed blocks summaries. Reason: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BlocksSummariesRepository();
|
||||||
|
|
@ -726,6 +726,16 @@ class Routes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getStrippedBlockTransactions(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash);
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
|
||||||
|
res.json(transactions);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getBlocks(req: Request, res: Response) {
|
public async getBlocks(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
if (['mainnet', 'testnet', 'signet', 'regtest'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
if (['mainnet', 'testnet', 'signet', 'regtest'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
||||||
|
@ -19,7 +19,8 @@
|
|||||||
"EXTERNAL_RETRY_INTERVAL": __MEMPOOL_EXTERNAL_RETRY_INTERVAL__,
|
"EXTERNAL_RETRY_INTERVAL": __MEMPOOL_EXTERNAL_RETRY_INTERVAL__,
|
||||||
"USER_AGENT": "__MEMPOOL_USER_AGENT__",
|
"USER_AGENT": "__MEMPOOL_USER_AGENT__",
|
||||||
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
|
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
|
||||||
"INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__
|
"INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__,
|
||||||
|
"BLOCKS_SUMMARIES_INDEXING": __MEMPOOL_BLOCKS_SUMMARIES_INDEXING__
|
||||||
},
|
},
|
||||||
"CORE_RPC": {
|
"CORE_RPC": {
|
||||||
"HOST": "__CORE_RPC_HOST__",
|
"HOST": "__CORE_RPC_HOST__",
|
||||||
|
@ -14,6 +14,7 @@ __MEMPOOL_BLOCK_WEIGHT_UNITS__=${MEMPOOL_BLOCK_WEIGHT_UNITS:=4000000}
|
|||||||
__MEMPOOL_INITIAL_BLOCKS_AMOUNT__=${MEMPOOL_INITIAL_BLOCKS_AMOUNT:=8}
|
__MEMPOOL_INITIAL_BLOCKS_AMOUNT__=${MEMPOOL_INITIAL_BLOCKS_AMOUNT:=8}
|
||||||
__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_MEMPOOL_BLOCKS_AMOUNT:=8}
|
__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_MEMPOOL_BLOCKS_AMOUNT:=8}
|
||||||
__MEMPOOL_INDEXING_BLOCKS_AMOUNT__=${MEMPOOL_INDEXING_BLOCKS_AMOUNT:=11000}
|
__MEMPOOL_INDEXING_BLOCKS_AMOUNT__=${MEMPOOL_INDEXING_BLOCKS_AMOUNT:=11000}
|
||||||
|
__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__=${MEMPOOL_BLOCKS_SUMMARIES_INDEXING:=false}
|
||||||
__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__=${MEMPOOL_PRICE_FEED_UPDATE_INTERVAL:=600}
|
__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__=${MEMPOOL_PRICE_FEED_UPDATE_INTERVAL:=600}
|
||||||
__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__=${MEMPOOL_USE_SECOND_NODE_FOR_MINFEE:=false}
|
__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__=${MEMPOOL_USE_SECOND_NODE_FOR_MINFEE:=false}
|
||||||
__MEMPOOL_EXTERNAL_ASSETS__=${MEMPOOL_EXTERNAL_ASSETS:=[]}
|
__MEMPOOL_EXTERNAL_ASSETS__=${MEMPOOL_EXTERNAL_ASSETS:=[]}
|
||||||
@ -101,6 +102,7 @@ sed -i "s/__MEMPOOL_BLOCK_WEIGHT_UNITS__/${__MEMPOOL_BLOCK_WEIGHT_UNITS__}/g" me
|
|||||||
sed -i "s/__MEMPOOL_INITIAL_BLOCKS_AMOUNT__/${__MEMPOOL_INITIAL_BLOCKS_AMOUNT__}/g" mempool-config.json
|
sed -i "s/__MEMPOOL_INITIAL_BLOCKS_AMOUNT__/${__MEMPOOL_INITIAL_BLOCKS_AMOUNT__}/g" mempool-config.json
|
||||||
sed -i "s/__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__/${__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__}/g" mempool-config.json
|
sed -i "s/__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__/${__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__}/g" mempool-config.json
|
||||||
sed -i "s/__MEMPOOL_INDEXING_BLOCKS_AMOUNT__/${__MEMPOOL_INDEXING_BLOCKS_AMOUNT__}/g" mempool-config.json
|
sed -i "s/__MEMPOOL_INDEXING_BLOCKS_AMOUNT__/${__MEMPOOL_INDEXING_BLOCKS_AMOUNT__}/g" mempool-config.json
|
||||||
|
sed -i "s/__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__/${__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__}/g" mempool-config.json
|
||||||
sed -i "s/__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__/${__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__}/g" mempool-config.json
|
sed -i "s/__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__/${__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__}/g" mempool-config.json
|
||||||
sed -i "s/__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__/${__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__}/g" mempool-config.json
|
sed -i "s/__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__/${__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__}/g" mempool-config.json
|
||||||
sed -i "s!__MEMPOOL_EXTERNAL_ASSETS__!${__MEMPOOL_EXTERNAL_ASSETS__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_EXTERNAL_ASSETS__!${__MEMPOOL_EXTERNAL_ASSETS__}!g" mempool-config.json
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
<div class="block-overview-graph">
|
||||||
|
<canvas class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas>
|
||||||
|
<div class="loader-wrapper" [class.hidden]="!isLoading">
|
||||||
|
<div class="spinner-border ml-3 loading" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-block-overview-tooltip
|
||||||
|
[tx]="selectedTx || hoverTx"
|
||||||
|
[cursorPosition]="tooltipPosition"
|
||||||
|
[clickable]="!!selectedTx"
|
||||||
|
></app-block-overview-tooltip>
|
||||||
|
</div>
|
@ -1,4 +1,4 @@
|
|||||||
.mempool-block-overview {
|
.block-overview-graph {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 100%;
|
padding-bottom: 100%;
|
||||||
@ -8,13 +8,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-overview {
|
|
||||||
|
.block-overview-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loader-wrapper {
|
.loader-wrapper {
|
||||||
@ -27,6 +34,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: opacity 500ms 500ms;
|
transition: opacity 500ms 500ms;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
@ -0,0 +1,430 @@
|
|||||||
|
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core';
|
||||||
|
import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||||
|
import { WebsocketService } from 'src/app/services/websocket.service';
|
||||||
|
import { FastVertexArray } from './fast-vertex-array';
|
||||||
|
import BlockScene from './block-scene';
|
||||||
|
import TxSprite from './tx-sprite';
|
||||||
|
import TxView from './tx-view';
|
||||||
|
import { Position } from './sprite-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-block-overview-graph',
|
||||||
|
templateUrl: './block-overview-graph.component.html',
|
||||||
|
styleUrls: ['./block-overview-graph.component.scss'],
|
||||||
|
})
|
||||||
|
export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy {
|
||||||
|
@Input() isLoading: boolean;
|
||||||
|
@Input() resolution: number;
|
||||||
|
@Input() blockLimit: number;
|
||||||
|
@Input() orientation = 'left';
|
||||||
|
@Input() flip = true;
|
||||||
|
@Output() txClickEvent = new EventEmitter<TransactionStripped>();
|
||||||
|
|
||||||
|
@ViewChild('blockCanvas')
|
||||||
|
canvas: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
gl: WebGLRenderingContext;
|
||||||
|
animationFrameRequest: number;
|
||||||
|
animationHeartBeat: number;
|
||||||
|
displayWidth: number;
|
||||||
|
displayHeight: number;
|
||||||
|
cssWidth: number;
|
||||||
|
cssHeight: number;
|
||||||
|
shaderProgram: WebGLProgram;
|
||||||
|
vertexArray: FastVertexArray;
|
||||||
|
running: boolean;
|
||||||
|
scene: BlockScene;
|
||||||
|
hoverTx: TxView | void;
|
||||||
|
selectedTx: TxView | void;
|
||||||
|
tooltipPosition: Position;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly ngZone: NgZone,
|
||||||
|
readonly elRef: ElementRef,
|
||||||
|
) {
|
||||||
|
this.vertexArray = new FastVertexArray(512, TxSprite.dataSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false);
|
||||||
|
this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false);
|
||||||
|
this.gl = this.canvas.nativeElement.getContext('webgl');
|
||||||
|
this.initCanvas();
|
||||||
|
|
||||||
|
this.resizeCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationFrameRequest) {
|
||||||
|
cancelAnimationFrame(this.animationFrameRequest);
|
||||||
|
clearTimeout(this.animationHeartBeat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(direction): void {
|
||||||
|
this.exit(direction);
|
||||||
|
this.hoverTx = null;
|
||||||
|
this.selectedTx = null;
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
enter(transactions: TransactionStripped[], direction: string): void {
|
||||||
|
if (this.scene) {
|
||||||
|
this.scene.enter(transactions, direction);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(direction: string): void {
|
||||||
|
if (this.scene) {
|
||||||
|
this.scene.exit(direction);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(transactions: TransactionStripped[], direction: string, sort: boolean = true): void {
|
||||||
|
if (this.scene) {
|
||||||
|
this.scene.replace(transactions || [], direction, sort);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void {
|
||||||
|
if (this.scene) {
|
||||||
|
this.scene.update(add, remove, direction, resetLayout);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initCanvas(): void {
|
||||||
|
this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
||||||
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
const shaderSet = [
|
||||||
|
{
|
||||||
|
type: this.gl.VERTEX_SHADER,
|
||||||
|
src: vertShaderSrc
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: this.gl.FRAGMENT_SHADER,
|
||||||
|
src: fragShaderSrc
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
this.shaderProgram = this.buildShaderProgram(shaderSet);
|
||||||
|
|
||||||
|
this.gl.useProgram(this.shaderProgram);
|
||||||
|
|
||||||
|
// Set up alpha blending
|
||||||
|
this.gl.enable(this.gl.BLEND);
|
||||||
|
this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
|
||||||
|
const glBuffer = this.gl.createBuffer();
|
||||||
|
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, glBuffer);
|
||||||
|
|
||||||
|
/* SET UP SHADER ATTRIBUTES */
|
||||||
|
Object.keys(attribs).forEach((key, i) => {
|
||||||
|
attribs[key].pointer = this.gl.getAttribLocation(this.shaderProgram, key);
|
||||||
|
this.gl.enableVertexAttribArray(attribs[key].pointer);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleContextLost(event): void {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelAnimationFrame(this.animationFrameRequest);
|
||||||
|
this.animationFrameRequest = null;
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleContextRestored(event): void {
|
||||||
|
this.initCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:resize', ['$event'])
|
||||||
|
resizeCanvas(): void {
|
||||||
|
this.cssWidth = this.canvas.nativeElement.offsetParent.clientWidth;
|
||||||
|
this.cssHeight = this.canvas.nativeElement.offsetParent.clientHeight;
|
||||||
|
this.displayWidth = window.devicePixelRatio * this.cssWidth;
|
||||||
|
this.displayHeight = window.devicePixelRatio * this.cssHeight;
|
||||||
|
this.canvas.nativeElement.width = this.displayWidth;
|
||||||
|
this.canvas.nativeElement.height = this.displayHeight;
|
||||||
|
if (this.gl) {
|
||||||
|
this.gl.viewport(0, 0, this.displayWidth, this.displayHeight);
|
||||||
|
}
|
||||||
|
if (this.scene) {
|
||||||
|
this.scene.resize({ width: this.displayWidth, height: this.displayHeight });
|
||||||
|
this.start();
|
||||||
|
} else {
|
||||||
|
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution,
|
||||||
|
blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray });
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileShader(src, type): WebGLShader {
|
||||||
|
const shader = this.gl.createShader(type);
|
||||||
|
|
||||||
|
this.gl.shaderSource(shader, src);
|
||||||
|
this.gl.compileShader(shader);
|
||||||
|
|
||||||
|
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
||||||
|
console.log(`Error compiling ${type === this.gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader:`);
|
||||||
|
console.log(this.gl.getShaderInfoLog(shader));
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildShaderProgram(shaderInfo): WebGLProgram {
|
||||||
|
const program = this.gl.createProgram();
|
||||||
|
|
||||||
|
shaderInfo.forEach((desc) => {
|
||||||
|
const shader = this.compileShader(desc.src, desc.type);
|
||||||
|
if (shader) {
|
||||||
|
this.gl.attachShader(program, shader);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.gl.linkProgram(program);
|
||||||
|
|
||||||
|
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
|
||||||
|
console.log('Error linking shader program:');
|
||||||
|
console.log(this.gl.getProgramInfoLog(program));
|
||||||
|
}
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.running = true;
|
||||||
|
this.ngZone.runOutsideAngular(() => this.doRun());
|
||||||
|
}
|
||||||
|
|
||||||
|
doRun(): void {
|
||||||
|
if (this.animationFrameRequest) {
|
||||||
|
cancelAnimationFrame(this.animationFrameRequest);
|
||||||
|
}
|
||||||
|
this.animationFrameRequest = requestAnimationFrame(() => this.run());
|
||||||
|
}
|
||||||
|
|
||||||
|
run(now?: DOMHighResTimeStamp): void {
|
||||||
|
if (!now) {
|
||||||
|
now = performance.now();
|
||||||
|
}
|
||||||
|
// skip re-render if there's no change to the scene
|
||||||
|
if (this.scene) {
|
||||||
|
/* SET UP SHADER UNIFORMS */
|
||||||
|
// screen dimensions
|
||||||
|
this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight);
|
||||||
|
// frame timestamp
|
||||||
|
this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, 'now'), now);
|
||||||
|
|
||||||
|
if (this.vertexArray.dirty) {
|
||||||
|
/* SET UP SHADER ATTRIBUTES */
|
||||||
|
Object.keys(attribs).forEach((key, i) => {
|
||||||
|
this.gl.vertexAttribPointer(attribs[key].pointer,
|
||||||
|
attribs[key].count, // number of primitives in this attribute
|
||||||
|
this.gl[attribs[key].type], // type of primitive in this attribute (e.g. gl.FLOAT)
|
||||||
|
false, // never normalised
|
||||||
|
stride, // distance between values of the same attribute
|
||||||
|
attribs[key].offset); // offset of the first value
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
this.vertexArray.dirty = false;
|
||||||
|
} else {
|
||||||
|
const pointArray = this.vertexArray.getVertexData();
|
||||||
|
if (pointArray.length) {
|
||||||
|
this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LOOP */
|
||||||
|
if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) {
|
||||||
|
this.doRun();
|
||||||
|
} else {
|
||||||
|
if (this.animationHeartBeat) {
|
||||||
|
clearTimeout(this.animationHeartBeat);
|
||||||
|
}
|
||||||
|
this.animationHeartBeat = window.setTimeout(() => {
|
||||||
|
this.start();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
clickAway(event) {
|
||||||
|
if (!this.elRef.nativeElement.contains(event.target)) {
|
||||||
|
const currentPreview = this.selectedTx || this.hoverTx;
|
||||||
|
if (currentPreview && this.scene) {
|
||||||
|
this.scene.setHover(currentPreview, false);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
this.hoverTx = null;
|
||||||
|
this.selectedTx = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('pointerup', ['$event'])
|
||||||
|
onClick(event) {
|
||||||
|
if (event.target === this.canvas.nativeElement && event.pointerType === 'touch') {
|
||||||
|
this.setPreviewTx(event.offsetX, event.offsetY, true);
|
||||||
|
} else if (event.target === this.canvas.nativeElement) {
|
||||||
|
this.onTxClick(event.offsetX, event.offsetY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('pointermove', ['$event'])
|
||||||
|
onPointerMove(event) {
|
||||||
|
if (event.target === this.canvas.nativeElement) {
|
||||||
|
this.setPreviewTx(event.offsetX, event.offsetY, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('pointerleave', ['$event'])
|
||||||
|
onPointerLeave(event) {
|
||||||
|
if (event.pointerType !== 'touch') {
|
||||||
|
this.setPreviewTx(-1, -1, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewTx(cssX: number, cssY: number, clicked: boolean = false) {
|
||||||
|
const x = cssX * window.devicePixelRatio;
|
||||||
|
const y = cssY * window.devicePixelRatio;
|
||||||
|
if (this.scene && (!this.selectedTx || clicked)) {
|
||||||
|
this.tooltipPosition = {
|
||||||
|
x: cssX,
|
||||||
|
y: cssY
|
||||||
|
};
|
||||||
|
const selected = this.scene.getTxAt({ x, y });
|
||||||
|
const currentPreview = this.selectedTx || this.hoverTx;
|
||||||
|
|
||||||
|
if (selected !== currentPreview) {
|
||||||
|
if (currentPreview && this.scene) {
|
||||||
|
this.scene.setHover(currentPreview, false);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
if (selected && this.scene) {
|
||||||
|
this.scene.setHover(selected, true);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
if (clicked) {
|
||||||
|
this.selectedTx = selected;
|
||||||
|
} else {
|
||||||
|
this.hoverTx = selected;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (clicked) {
|
||||||
|
this.selectedTx = null;
|
||||||
|
}
|
||||||
|
this.hoverTx = null;
|
||||||
|
}
|
||||||
|
} else if (clicked) {
|
||||||
|
if (selected === this.selectedTx) {
|
||||||
|
this.hoverTx = this.selectedTx;
|
||||||
|
this.selectedTx = null;
|
||||||
|
} else {
|
||||||
|
this.selectedTx = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTxClick(cssX: number, cssY: number) {
|
||||||
|
const x = cssX * window.devicePixelRatio;
|
||||||
|
const y = cssY * window.devicePixelRatio;
|
||||||
|
const selected = this.scene.getTxAt({ x, y });
|
||||||
|
if (selected && selected.txid) {
|
||||||
|
this.txClickEvent.emit(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebGL shader attributes
|
||||||
|
const attribs = {
|
||||||
|
offset: { type: 'FLOAT', count: 2, pointer: null, offset: 0 },
|
||||||
|
posX: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||||
|
posY: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||||
|
posR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||||
|
colR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||||
|
colG: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||||
|
colB: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||||
|
colA: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }
|
||||||
|
};
|
||||||
|
// Calculate the number of bytes per vertex based on specified attributes
|
||||||
|
const stride = Object.values(attribs).reduce((total, attrib) => {
|
||||||
|
return total + (attrib.count * 4);
|
||||||
|
}, 0);
|
||||||
|
// Calculate vertex attribute offsets
|
||||||
|
for (let i = 0, offset = 0; i < Object.keys(attribs).length; i++) {
|
||||||
|
const attrib = Object.values(attribs)[i];
|
||||||
|
attrib.offset = offset;
|
||||||
|
offset += (attrib.count * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vertShaderSrc = `
|
||||||
|
varying lowp vec4 vColor;
|
||||||
|
|
||||||
|
// each attribute contains [x: startValue, y: endValue, z: startTime, w: rate]
|
||||||
|
// shader interpolates between start and end values at the given rate, from the given time
|
||||||
|
|
||||||
|
attribute vec2 offset;
|
||||||
|
attribute vec4 posX;
|
||||||
|
attribute vec4 posY;
|
||||||
|
attribute vec4 posR;
|
||||||
|
attribute vec4 colR;
|
||||||
|
attribute vec4 colG;
|
||||||
|
attribute vec4 colB;
|
||||||
|
attribute vec4 colA;
|
||||||
|
|
||||||
|
uniform vec2 screenSize;
|
||||||
|
uniform float now;
|
||||||
|
|
||||||
|
float smootherstep(float x) {
|
||||||
|
x = clamp(x, 0.0, 1.0);
|
||||||
|
float ix = 1.0 - x;
|
||||||
|
x = x * x;
|
||||||
|
return x / (x + ix * ix);
|
||||||
|
}
|
||||||
|
|
||||||
|
float interpolateAttribute(vec4 attr) {
|
||||||
|
float d = (now - attr.z) * attr.w;
|
||||||
|
float delta = smootherstep(d);
|
||||||
|
return mix(attr.x, attr.y, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 screenTransform = vec4(2.0 / screenSize.x, 2.0 / screenSize.y, -1.0, -1.0);
|
||||||
|
// vec4 screenTransform = vec4(1.0 / screenSize.x, 1.0 / screenSize.y, -0.5, -0.5);
|
||||||
|
|
||||||
|
float radius = interpolateAttribute(posR);
|
||||||
|
vec2 position = vec2(interpolateAttribute(posX), interpolateAttribute(posY)) + (radius * offset);
|
||||||
|
|
||||||
|
gl_Position = vec4(position * screenTransform.xy + screenTransform.zw, 1.0, 1.0);
|
||||||
|
|
||||||
|
float red = interpolateAttribute(colR);
|
||||||
|
float green = interpolateAttribute(colG);
|
||||||
|
float blue = interpolateAttribute(colB);
|
||||||
|
float alpha = interpolateAttribute(colA);
|
||||||
|
|
||||||
|
vColor = vec4(red, green, blue, alpha);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fragShaderSrc = `
|
||||||
|
varying lowp vec4 vColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = vColor;
|
||||||
|
// premultiply alpha
|
||||||
|
gl_FragColor.rgb *= gl_FragColor.a;
|
||||||
|
}
|
||||||
|
`;
|
784
frontend/src/app/components/block-overview-graph/block-scene.ts
Normal file
784
frontend/src/app/components/block-overview-graph/block-scene.ts
Normal file
@ -0,0 +1,784 @@
|
|||||||
|
import { FastVertexArray } from './fast-vertex-array';
|
||||||
|
import TxView from './tx-view';
|
||||||
|
import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||||
|
import { Position, Square, ViewUpdateParams } from './sprite-types';
|
||||||
|
|
||||||
|
export default class BlockScene {
|
||||||
|
scene: { count: number, offset: { x: number, y: number}};
|
||||||
|
vertexArray: FastVertexArray;
|
||||||
|
txs: { [key: string]: TxView };
|
||||||
|
orientation: string;
|
||||||
|
flip: boolean;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
gridWidth: number;
|
||||||
|
gridHeight: number;
|
||||||
|
gridSize: number;
|
||||||
|
vbytesPerUnit: number;
|
||||||
|
unitPadding: number;
|
||||||
|
unitWidth: number;
|
||||||
|
initialised: boolean;
|
||||||
|
layout: BlockLayout;
|
||||||
|
animateUntil = 0;
|
||||||
|
dirty: boolean;
|
||||||
|
|
||||||
|
constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray }:
|
||||||
|
{ width: number, height: number, resolution: number, blockLimit: number,
|
||||||
|
orientation: string, flip: boolean, vertexArray: FastVertexArray }
|
||||||
|
) {
|
||||||
|
this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray });
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
Object.values(this.txs).forEach(tx => tx.destroy());
|
||||||
|
}
|
||||||
|
|
||||||
|
resize({ width = this.width, height = this.height }: { width?: number, height?: number}): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.gridSize = this.width / this.gridWidth;
|
||||||
|
this.unitPadding = width / 500;
|
||||||
|
this.unitWidth = this.gridSize - (this.unitPadding * 2);
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
if (this.initialised && this.scene) {
|
||||||
|
this.updateAll(performance.now(), 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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', sort: boolean = true): 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 });
|
||||||
|
|
||||||
|
if (sort) {
|
||||||
|
Object.values(this.txs).sort(feeRateDescending).forEach(tx => {
|
||||||
|
this.place(tx);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
txs.forEach(tx => {
|
||||||
|
this.place(this.txs[tx.txid]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateAll(startTime, 200, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void {
|
||||||
|
const startTime = performance.now();
|
||||||
|
const removed = this.removeBatch(remove, startTime, direction);
|
||||||
|
|
||||||
|
// clean up sprites
|
||||||
|
setTimeout(() => {
|
||||||
|
removed.forEach(tx => {
|
||||||
|
tx.destroy();
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
if (resetLayout) {
|
||||||
|
add.forEach(tx => {
|
||||||
|
if (!this.txs[tx.txid]) {
|
||||||
|
this.txs[tx.txid] = new TxView(tx, this.vertexArray);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight });
|
||||||
|
Object.values(this.txs).sort(feeRateDescending).forEach(tx => {
|
||||||
|
this.place(tx);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// try to insert new txs directly
|
||||||
|
const remaining = [];
|
||||||
|
add.map(tx => new TxView(tx, this.vertexArray)).sort(feeRateDescending).forEach(tx => {
|
||||||
|
if (!this.tryInsertByFee(tx)) {
|
||||||
|
remaining.push(tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.placeBatch(remaining);
|
||||||
|
this.layout.applyGravity();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateAll(startTime, 100, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHover(tx: TxView, value: boolean): void {
|
||||||
|
this.animateUntil = Math.max(this.animateUntil, tx.setHover(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }:
|
||||||
|
{ width: number, height: number, resolution: number, blockLimit: number,
|
||||||
|
orientation: string, flip: boolean, vertexArray: FastVertexArray }
|
||||||
|
): void {
|
||||||
|
this.orientation = orientation;
|
||||||
|
this.flip = flip;
|
||||||
|
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.02, 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 applyTxUpdate(tx: TxView, update: ViewUpdateParams): void {
|
||||||
|
this.animateUntil = Math.max(this.animateUntil, tx.update(update));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left'): void {
|
||||||
|
if (tx.dirty || this.dirty) {
|
||||||
|
this.saveGridToScreenPosition(tx);
|
||||||
|
this.setTxOnScreen(tx, startTime, delay, direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left'): void {
|
||||||
|
if (!tx.initialised) {
|
||||||
|
const txColor = tx.getColor();
|
||||||
|
this.applyTxUpdate(tx, {
|
||||||
|
display: {
|
||||||
|
position: {
|
||||||
|
x: tx.screenPosition.x + (direction === 'right' ? -this.width : (direction === 'left' ? this.width : 0)) * 1.4,
|
||||||
|
y: tx.screenPosition.y + (direction === 'up' ? -this.height : (direction === 'down' ? this.height : 0)) * 1.4,
|
||||||
|
s: tx.screenPosition.s
|
||||||
|
},
|
||||||
|
color: txColor,
|
||||||
|
},
|
||||||
|
start: startTime,
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
this.applyTxUpdate(tx, {
|
||||||
|
display: {
|
||||||
|
position: tx.screenPosition,
|
||||||
|
color: txColor
|
||||||
|
},
|
||||||
|
duration: 1000,
|
||||||
|
start: startTime,
|
||||||
|
delay,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.applyTxUpdate(tx, {
|
||||||
|
display: {
|
||||||
|
position: tx.screenPosition
|
||||||
|
},
|
||||||
|
duration: 1000,
|
||||||
|
minDuration: 500,
|
||||||
|
start: startTime,
|
||||||
|
delay,
|
||||||
|
adjust: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateAll(startTime: number, delay: number = 50, direction: string = 'left'): void {
|
||||||
|
this.scene.count = 0;
|
||||||
|
const ids = this.getTxList();
|
||||||
|
startTime = startTime || performance.now();
|
||||||
|
for (const id of ids) {
|
||||||
|
this.updateTx(this.txs[id], startTime, delay, 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);
|
||||||
|
this.applyTxUpdate(tx, {
|
||||||
|
display: {
|
||||||
|
position: {
|
||||||
|
x: tx.screenPosition.x + (direction === 'right' ? this.width : (direction === 'left' ? -this.width : 0)) * 1.4,
|
||||||
|
y: tx.screenPosition.y + (direction === 'up' ? this.height : (direction === 'down' ? -this.height : 0)) * 1.4,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
duration: 1000,
|
||||||
|
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 and/or flip the y axis to match the target configuration.
|
||||||
|
//
|
||||||
|
// e.g. for flip = true, orientation = 'left':
|
||||||
|
//
|
||||||
|
// grid screen
|
||||||
|
// ________ ________ ________
|
||||||
|
// | | | | | a|
|
||||||
|
// | | flip | | rotate | c |
|
||||||
|
// | c | --> | c | --> | |
|
||||||
|
// |a______b| |b______a| |_______b|
|
||||||
|
|
||||||
|
let x = (this.gridSize * position.x) + (slotSize / 2);
|
||||||
|
let y = (this.gridSize * position.y) + (slotSize / 2);
|
||||||
|
let t;
|
||||||
|
if (this.flip) {
|
||||||
|
x = this.width - x;
|
||||||
|
}
|
||||||
|
switch (this.orientation) {
|
||||||
|
case 'left':
|
||||||
|
t = x;
|
||||||
|
x = this.width - y;
|
||||||
|
y = t;
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
t = x;
|
||||||
|
x = y;
|
||||||
|
y = t;
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
y = this.height - y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: x + this.unitPadding - (slotSize / 2),
|
||||||
|
y: y + this.unitPadding - (slotSize / 2),
|
||||||
|
s: squareSize
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return { x: 0, y: 0, s: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private screenToGrid(position: Position): Position {
|
||||||
|
let x = position.x;
|
||||||
|
let y = this.height - position.y;
|
||||||
|
let t;
|
||||||
|
|
||||||
|
switch (this.orientation) {
|
||||||
|
case 'left':
|
||||||
|
t = x;
|
||||||
|
x = y;
|
||||||
|
y = this.width - t;
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
t = x;
|
||||||
|
x = y;
|
||||||
|
y = t;
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
y = this.height - y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (this.flip) {
|
||||||
|
x = this.width - x;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: Math.floor(x / this.gridSize),
|
||||||
|
y: Math.floor(y / this.gridSize)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculates and returns the size of the tx in multiples of the grid size
|
||||||
|
private txSize(tx: TxView): number {
|
||||||
|
const scale = Math.max(1, Math.round(Math.sqrt(tx.vsize / this.vbytesPerUnit)));
|
||||||
|
return Math.min(this.gridWidth, Math.max(1, scale)); // bound between 1 and the max displayable size (just in case!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private place(tx: TxView): void {
|
||||||
|
const size = this.txSize(tx);
|
||||||
|
this.layout.insert(tx, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryInsertByFee(tx: TxView): boolean {
|
||||||
|
const size = this.txSize(tx);
|
||||||
|
const position = this.layout.tryInsertByFee(tx, size);
|
||||||
|
if (position) {
|
||||||
|
this.txs[tx.txid] = tx;
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a list of transactions to the layout,
|
||||||
|
// keeping everything approximately sorted by feerate.
|
||||||
|
private placeBatch(txs: TxView[]): void {
|
||||||
|
if (txs.length) {
|
||||||
|
// grab the new tx with the highest fee rate
|
||||||
|
txs = txs.sort(feeRateDescending);
|
||||||
|
const maxSize = 2 * txs.reduce((max, tx) => {
|
||||||
|
return Math.max(this.txSize(tx), max);
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
// find a reasonable place for it in the layout
|
||||||
|
const root = this.layout.getReplacementRoot(txs[0].feerate, maxSize);
|
||||||
|
|
||||||
|
// extract a sub tree of transactions from the layout, rooted at that point
|
||||||
|
const popped = this.layout.popTree(root.x, root.y, maxSize);
|
||||||
|
// combine those with the new transactions and sort
|
||||||
|
txs = txs.concat(popped);
|
||||||
|
txs = txs.sort(feeRateDescending);
|
||||||
|
|
||||||
|
// insert everything back into the layout
|
||||||
|
txs.forEach(tx => {
|
||||||
|
this.txs[tx.txid] = tx;
|
||||||
|
this.place(tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeBatch(ids: string[], startTime: number, direction: string = 'left'): TxView[] {
|
||||||
|
if (!startTime) {
|
||||||
|
startTime = performance.now();
|
||||||
|
}
|
||||||
|
return ids.map(id => {
|
||||||
|
return this.remove(id, startTime, direction);
|
||||||
|
}).filter(tx => tx != null) as TxView[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Slot {
|
||||||
|
l: number;
|
||||||
|
r: number;
|
||||||
|
w: number;
|
||||||
|
|
||||||
|
constructor(l: number, r: number) {
|
||||||
|
this.l = l;
|
||||||
|
this.r = r;
|
||||||
|
this.w = r - l;
|
||||||
|
}
|
||||||
|
|
||||||
|
intersects(slot: Slot): boolean {
|
||||||
|
return !((slot.r <= this.l) || (slot.l >= this.r));
|
||||||
|
}
|
||||||
|
|
||||||
|
subtract(slot: Slot): Slot[] | void {
|
||||||
|
if (this.intersects(slot)) {
|
||||||
|
// from middle
|
||||||
|
if (slot.l > this.l && slot.r < this.r) {
|
||||||
|
return [
|
||||||
|
new Slot(this.l, slot.l),
|
||||||
|
new Slot(slot.r, this.r)
|
||||||
|
];
|
||||||
|
} // totally covered
|
||||||
|
else if (slot.l <= this.l && slot.r >= this.r) {
|
||||||
|
return [];
|
||||||
|
} // from left side
|
||||||
|
else if (slot.l <= this.l) {
|
||||||
|
if (slot.r === this.r) {
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
return [new Slot(slot.r, this.r)];
|
||||||
|
}
|
||||||
|
} // from right side
|
||||||
|
else if (slot.r >= this.r) {
|
||||||
|
if (slot.l === this.l) {
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
return [new Slot(this.l, slot.l)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return [this];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TxSlot extends Slot {
|
||||||
|
tx: TxView;
|
||||||
|
|
||||||
|
constructor(l: number, r: number, tx: TxView) {
|
||||||
|
super(l, r);
|
||||||
|
this.tx = tx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Row {
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
filled: TxSlot[];
|
||||||
|
slots: Slot[];
|
||||||
|
|
||||||
|
|
||||||
|
constructor(y: number, width: number) {
|
||||||
|
this.y = y;
|
||||||
|
this.w = width;
|
||||||
|
this.filled = [];
|
||||||
|
this.slots = [new Slot(0, this.w)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert a transaction w/ given width into row starting at position x
|
||||||
|
insert(x: number, w: number, tx: TxView): void {
|
||||||
|
const newSlot = new TxSlot(x, x + w, tx);
|
||||||
|
// insert into filled list
|
||||||
|
let index = this.filled.findIndex((slot) => (slot.l >= newSlot.r));
|
||||||
|
if (index < 0) {
|
||||||
|
index = this.filled.length;
|
||||||
|
}
|
||||||
|
this.filled.splice(index || 0, 0, newSlot);
|
||||||
|
// subtract from overlapping slots
|
||||||
|
for (let i = 0; i < this.slots.length; i++) {
|
||||||
|
if (newSlot.intersects(this.slots[i])) {
|
||||||
|
const diff = this.slots[i].subtract(newSlot);
|
||||||
|
if (diff) {
|
||||||
|
this.slots.splice(i, 1, ...diff);
|
||||||
|
i += diff.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(x: number, w: number): void {
|
||||||
|
const txIndex = this.filled.findIndex((slot) => (slot.l === x) );
|
||||||
|
this.filled.splice(txIndex, 1);
|
||||||
|
|
||||||
|
const newSlot = new Slot(x, x + w);
|
||||||
|
let slotIndex = this.slots.findIndex((slot) => (slot.l >= newSlot.r) );
|
||||||
|
if (slotIndex < 0) {
|
||||||
|
slotIndex = this.slots.length;
|
||||||
|
}
|
||||||
|
this.slots.splice(slotIndex || 0, 0, newSlot);
|
||||||
|
this.normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge any contiguous empty slots
|
||||||
|
private normalize(): void {
|
||||||
|
for (let i = 0; i < this.slots.length - 1; i++) {
|
||||||
|
if (this.slots[i].r === this.slots[i + 1].l) {
|
||||||
|
this.slots[i].r = this.slots[i + 1].r;
|
||||||
|
this.slots[i].w += this.slots[i + 1].w;
|
||||||
|
this.slots.splice(i + 1, 1);
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
txAt(x: number): TxView | void {
|
||||||
|
let i = 0;
|
||||||
|
while (i < this.filled.length && this.filled[i].l <= x) {
|
||||||
|
if (this.filled[i].l <= x && this.filled[i].r > x) {
|
||||||
|
return this.filled[i].tx;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSlotsBetween(left: number, right: number): TxSlot[] {
|
||||||
|
const range = new Slot(left, right);
|
||||||
|
return this.filled.filter(slot => {
|
||||||
|
return slot.intersects(range);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
slotAt(x: number): Slot | void {
|
||||||
|
let i = 0;
|
||||||
|
while (i < this.slots.length && this.slots[i].l <= x) {
|
||||||
|
if (this.slots[i].l <= x && this.slots[i].r > x) {
|
||||||
|
return this.slots[i];
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvgFeerate(): number {
|
||||||
|
let count = 0;
|
||||||
|
let total = 0;
|
||||||
|
this.filled.forEach(slot => {
|
||||||
|
if (slot.tx) {
|
||||||
|
count += slot.w;
|
||||||
|
total += (slot.tx.feerate * slot.w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return total / count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockLayout {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rows: Row[];
|
||||||
|
txPositions: { [key: string]: Square };
|
||||||
|
txs: { [key: string]: TxView };
|
||||||
|
|
||||||
|
constructor({ width, height }: { width: number, height: number }) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.rows = [new Row(0, this.width)];
|
||||||
|
this.txPositions = {};
|
||||||
|
this.txs = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
getRow(position: Square): Row {
|
||||||
|
return this.rows[position.y];
|
||||||
|
}
|
||||||
|
|
||||||
|
getTx(position: Square): TxView | void {
|
||||||
|
if (this.getRow(position)) {
|
||||||
|
return this.getRow(position).txAt(position.x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addRow(): void {
|
||||||
|
this.rows.push(new Row(this.rows.length, this.width));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(tx: TxView) {
|
||||||
|
const position = this.txPositions[tx.txid];
|
||||||
|
if (position) {
|
||||||
|
for (let y = position.y; y < position.y + position.s && y < this.rows.length; y++) {
|
||||||
|
this.rows[y].remove(position.x, position.s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete this.txPositions[tx.txid];
|
||||||
|
delete this.txs[tx.txid];
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(tx: TxView, width: number): Square {
|
||||||
|
const fit = this.fit(tx, width);
|
||||||
|
|
||||||
|
// insert the tx into rows at that position
|
||||||
|
for (let y = fit.y; y < fit.y + width; y++) {
|
||||||
|
if (y >= this.rows.length) {
|
||||||
|
this.addRow();
|
||||||
|
}
|
||||||
|
this.rows[y].insert(fit.x, width, tx);
|
||||||
|
}
|
||||||
|
const position = { x: fit.x, y: fit.y, s: width };
|
||||||
|
this.txPositions[tx.txid] = position;
|
||||||
|
this.txs[tx.txid] = tx;
|
||||||
|
tx.applyGridPosition(position);
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first slot large enough to hold a transaction of this size
|
||||||
|
fit(tx: TxView, width: number): Square {
|
||||||
|
let fit;
|
||||||
|
for (let y = 0; y < this.rows.length && !fit; y++) {
|
||||||
|
fit = this.findFit(0, this.width, y, y, width);
|
||||||
|
}
|
||||||
|
// fall back to placing tx in a new row at the top of the layout
|
||||||
|
if (!fit) {
|
||||||
|
fit = { x: 0, y: this.rows.length };
|
||||||
|
}
|
||||||
|
return fit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// recursively check rows to see if there's space for a tx (depth-first)
|
||||||
|
// left/right: initial column boundaries to check
|
||||||
|
// row: current row to check
|
||||||
|
// start: starting row
|
||||||
|
// size: size of space needed
|
||||||
|
findFit(left: number, right: number, row: number, start: number, size: number): Square {
|
||||||
|
if ((row - start) >= size || row >= this.rows.length) {
|
||||||
|
return { x: left, y: start };
|
||||||
|
}
|
||||||
|
for (const slot of this.rows[row].slots) {
|
||||||
|
const l = Math.max(left, slot.l);
|
||||||
|
const r = Math.min(right, slot.r);
|
||||||
|
if (r - l >= size) {
|
||||||
|
const fit = this.findFit(l, r, row + 1, start, size);
|
||||||
|
if (fit) {
|
||||||
|
return fit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert only if the tx fits into a fee-appropriate position
|
||||||
|
tryInsertByFee(tx: TxView, size: number): Square | void {
|
||||||
|
const fit = this.fit(tx, size);
|
||||||
|
|
||||||
|
if (this.checkRowFees(fit.y, tx.feerate)) {
|
||||||
|
// insert the tx into rows at that position
|
||||||
|
for (let y = fit.y; y < fit.y + size; y++) {
|
||||||
|
if (y >= this.rows.length) {
|
||||||
|
this.addRow();
|
||||||
|
}
|
||||||
|
this.rows[y].insert(fit.x, size, tx);
|
||||||
|
}
|
||||||
|
const position = { x: fit.x, y: fit.y, s: size };
|
||||||
|
this.txPositions[tx.txid] = position;
|
||||||
|
this.txs[tx.txid] = tx;
|
||||||
|
tx.applyGridPosition(position);
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the first slot with a lower feerate
|
||||||
|
getReplacementRoot(feerate: number, width: number): Square {
|
||||||
|
let slot;
|
||||||
|
for (let row = 0; row <= this.rows.length; row++) {
|
||||||
|
if (this.rows[row].slots.length > 0) {
|
||||||
|
return { x: this.rows[row].slots[0].l, y: row };
|
||||||
|
} else {
|
||||||
|
slot = this.rows[row].filled.find(x => {
|
||||||
|
return x.tx.feerate < feerate;
|
||||||
|
});
|
||||||
|
if (slot) {
|
||||||
|
return { x: Math.min(slot.l, this.width - width), y: row };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: 0, y: this.rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove and return all transactions in a subtree of the layout
|
||||||
|
popTree(x: number, y: number, width: number) {
|
||||||
|
const selected: { [key: string]: TxView } = {};
|
||||||
|
let left = x;
|
||||||
|
let right = x + width;
|
||||||
|
let prevWidth = right - left;
|
||||||
|
let prevFee = Infinity;
|
||||||
|
// scan rows upwards within a channel bounded by 'left' and 'right'
|
||||||
|
for (let row = y; row < this.rows.length; row++) {
|
||||||
|
let rowMax = 0;
|
||||||
|
const slots = this.rows[row].getSlotsBetween(left, right);
|
||||||
|
// check each slot in this row overlapping the search channel
|
||||||
|
slots.forEach(slot => {
|
||||||
|
// select the associated transaction
|
||||||
|
selected[slot.tx.txid] = slot.tx;
|
||||||
|
rowMax = Math.max(rowMax, slot.tx.feerate);
|
||||||
|
// widen the search channel to accommodate this slot if necessary
|
||||||
|
if (slot.w > prevWidth) {
|
||||||
|
left = slot.l;
|
||||||
|
right = slot.r;
|
||||||
|
// if this slot's tx has a higher feerate than the max in the previous row
|
||||||
|
// (i.e. it's out of position)
|
||||||
|
// select all txs overlapping the slot's full width in some rows *below*
|
||||||
|
// to free up space for this tx to sink down to its proper position
|
||||||
|
if (slot.tx.feerate > prevFee) {
|
||||||
|
let count = 0;
|
||||||
|
// keep scanning back down until we find a full row of higher-feerate txs
|
||||||
|
for (let echo = row - 1; echo >= 0 && count < slot.w; echo--) {
|
||||||
|
const echoSlots = this.rows[echo].getSlotsBetween(slot.l, slot.r);
|
||||||
|
count = 0;
|
||||||
|
echoSlots.forEach(echoSlot => {
|
||||||
|
selected[echoSlot.tx.txid] = echoSlot.tx;
|
||||||
|
if (echoSlot.tx.feerate >= slot.tx.feerate) {
|
||||||
|
count += echoSlot.w;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
prevWidth = right - left;
|
||||||
|
prevFee = rowMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
const txList = Object.values(selected);
|
||||||
|
|
||||||
|
txList.forEach(tx => {
|
||||||
|
this.remove(tx);
|
||||||
|
});
|
||||||
|
return txList;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this row has high enough avg fees
|
||||||
|
// for a tx with this feerate to make sense here
|
||||||
|
checkRowFees(row: number, targetFee: number): boolean {
|
||||||
|
// first row is always fine
|
||||||
|
if (row === 0 || !this.rows[row]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (this.rows[row].getAvgFeerate() > (targetFee * 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop any free-floating transactions down into empty spaces
|
||||||
|
applyGravity(): void {
|
||||||
|
Object.entries(this.txPositions).sort(([keyA, posA], [keyB, posB]) => {
|
||||||
|
return posA.y - posB.y || posA.x - posB.x;
|
||||||
|
}).forEach(([txid, position]) => {
|
||||||
|
// see how far this transaction can fall
|
||||||
|
let dropTo = position.y;
|
||||||
|
while (dropTo > 0 && !this.rows[dropTo - 1].getSlotsBetween(position.x, position.x + position.s).length) {
|
||||||
|
dropTo--;
|
||||||
|
}
|
||||||
|
// if it can fall at all
|
||||||
|
if (dropTo < position.y) {
|
||||||
|
// remove and reinsert in the row we found
|
||||||
|
const tx = this.txs[txid];
|
||||||
|
this.remove(tx);
|
||||||
|
this.insert(tx, position.s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function feeRateDescending(a: TxView, b: TxView) {
|
||||||
|
return b.feerate - a.feerate;
|
||||||
|
}
|
@ -18,6 +18,7 @@ export class FastVertexArray {
|
|||||||
data: Float32Array;
|
data: Float32Array;
|
||||||
freeSlots: number[];
|
freeSlots: number[];
|
||||||
lastSlot: number;
|
lastSlot: number;
|
||||||
|
dirty = false;
|
||||||
|
|
||||||
constructor(length, stride) {
|
constructor(length, stride) {
|
||||||
this.length = length;
|
this.length = length;
|
||||||
@ -27,6 +28,7 @@ export class FastVertexArray {
|
|||||||
this.data = new Float32Array(this.length * this.stride);
|
this.data = new Float32Array(this.length * this.stride);
|
||||||
this.freeSlots = [];
|
this.freeSlots = [];
|
||||||
this.lastSlot = 0;
|
this.lastSlot = 0;
|
||||||
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
insert(sprite: TxSprite): number {
|
insert(sprite: TxSprite): number {
|
||||||
@ -44,6 +46,7 @@ export class FastVertexArray {
|
|||||||
}
|
}
|
||||||
this.sprites[position] = sprite;
|
this.sprites[position] = sprite;
|
||||||
return position;
|
return position;
|
||||||
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(index: number): void {
|
remove(index: number): void {
|
||||||
@ -54,14 +57,17 @@ export class FastVertexArray {
|
|||||||
if (this.length > 2048 && this.count < (this.length * 0.4)) {
|
if (this.length > 2048 && this.count < (this.length * 0.4)) {
|
||||||
this.compact();
|
this.compact();
|
||||||
}
|
}
|
||||||
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(index: number, dataChunk: number[]): void {
|
setData(index: number, dataChunk: number[]): void {
|
||||||
this.data.set(dataChunk, (index * this.stride));
|
this.data.set(dataChunk, (index * this.stride));
|
||||||
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearData(index: number): void {
|
clearData(index: number): void {
|
||||||
this.data.fill(0, (index * this.stride), ((index + 1) * this.stride));
|
this.data.fill(0, (index * this.stride), ((index + 1) * this.stride));
|
||||||
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
getData(index: number): Float32Array {
|
getData(index: number): Float32Array {
|
||||||
@ -73,6 +79,7 @@ export class FastVertexArray {
|
|||||||
const newData = new Float32Array(this.length * this.stride);
|
const newData = new Float32Array(this.length * this.stride);
|
||||||
newData.set(this.data);
|
newData.set(this.data);
|
||||||
this.data = newData;
|
this.data = newData;
|
||||||
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
compact(): void {
|
compact(): void {
|
||||||
@ -97,6 +104,7 @@ export class FastVertexArray {
|
|||||||
this.freeSlots = [];
|
this.freeSlots = [];
|
||||||
this.lastSlot = i;
|
this.lastSlot = i;
|
||||||
}
|
}
|
||||||
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
getVertexData(): Float32Array {
|
getVertexData(): Float32Array {
|
@ -82,8 +82,10 @@ export default class TxView implements TransactionStripped {
|
|||||||
delay: additional milliseconds to wait before starting
|
delay: additional milliseconds to wait before starting
|
||||||
jitter: if set, adds a random amount to the delay,
|
jitter: if set, adds a random amount to the delay,
|
||||||
adjust: if true, modify an in-progress transition instead of replacing it
|
adjust: if true, modify an in-progress transition instead of replacing it
|
||||||
|
|
||||||
|
returns minimum transition end time
|
||||||
*/
|
*/
|
||||||
update(params: ViewUpdateParams): void {
|
update(params: ViewUpdateParams): number {
|
||||||
if (params.jitter) {
|
if (params.jitter) {
|
||||||
params.delay += (Math.random() * params.jitter);
|
params.delay += (Math.random() * params.jitter);
|
||||||
}
|
}
|
||||||
@ -96,6 +98,7 @@ export default class TxView implements TransactionStripped {
|
|||||||
);
|
);
|
||||||
// apply any pending hover event
|
// apply any pending hover event
|
||||||
if (this.hover) {
|
if (this.hover) {
|
||||||
|
params.duration = Math.max(params.duration, hoverTransitionTime);
|
||||||
this.sprite.update({
|
this.sprite.update({
|
||||||
...this.hoverColor,
|
...this.hoverColor,
|
||||||
duration: hoverTransitionTime,
|
duration: hoverTransitionTime,
|
||||||
@ -109,10 +112,12 @@ export default class TxView implements TransactionStripped {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.dirty = false;
|
this.dirty = false;
|
||||||
|
return (params.start || performance.now()) + (params.delay || 0) + (params.duration || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temporarily override the tx color
|
// Temporarily override the tx color
|
||||||
setHover(hoverOn: boolean, color: Color | void = defaultHoverColor): void {
|
// returns minimum transition end time
|
||||||
|
setHover(hoverOn: boolean, color: Color | void = defaultHoverColor): number {
|
||||||
if (hoverOn) {
|
if (hoverOn) {
|
||||||
this.hover = true;
|
this.hover = true;
|
||||||
this.hoverColor = color;
|
this.hoverColor = color;
|
||||||
@ -131,6 +136,7 @@ export default class TxView implements TransactionStripped {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.dirty = false;
|
this.dirty = false;
|
||||||
|
return performance.now() + hoverTransitionTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
getColor(): Color {
|
getColor(): Color {
|
@ -0,0 +1,37 @@
|
|||||||
|
<div
|
||||||
|
#tooltip
|
||||||
|
class="block-overview-tooltip"
|
||||||
|
[class.clickable]="clickable"
|
||||||
|
[style.visibility]="tx ? 'visible' : 'hidden'"
|
||||||
|
[style.left]="tooltipPosition.x + 'px'"
|
||||||
|
[style.top]="tooltipPosition.y + 'px'"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td i18n="shared.transaction">Transaction</td>
|
||||||
|
<td>
|
||||||
|
<a [routerLink]="['/tx/' | relativeUrl, txid]">{{ txid | shortenString : 16}}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="td-width" i18n="transaction.value|Transaction value">Value</td>
|
||||||
|
<td><app-amount [satoshis]="value"></app-amount></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
|
||||||
|
<td>{{ fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> <span class="fiat"><app-fiat [value]="fee"></app-fiat></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
|
||||||
|
<td>
|
||||||
|
{{ feeRate | 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]="'‎' + (vsize | vbytes: 2)"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
@ -0,0 +1,18 @@
|
|||||||
|
.block-overview-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(#11131f, 0.95);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 1px 1px 10px rgba(0,0,0,0.5);
|
||||||
|
color: #b1b1b1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 15px;
|
||||||
|
text-align: left;
|
||||||
|
width: 320px;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||||
|
import { Position } from 'src/app/components/block-overview-graph/sprite-types.js';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-block-overview-tooltip',
|
||||||
|
templateUrl: './block-overview-tooltip.component.html',
|
||||||
|
styleUrls: ['./block-overview-tooltip.component.scss'],
|
||||||
|
})
|
||||||
|
export class BlockOverviewTooltipComponent implements OnChanges {
|
||||||
|
@Input() tx: TransactionStripped | void;
|
||||||
|
@Input() cursorPosition: Position;
|
||||||
|
@Input() clickable: boolean;
|
||||||
|
|
||||||
|
txid = '';
|
||||||
|
fee = 0;
|
||||||
|
value = 0;
|
||||||
|
vsize = 1;
|
||||||
|
feeRate = 0;
|
||||||
|
|
||||||
|
tooltipPosition: Position = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
@ViewChild('tooltip') tooltipElement: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
ngOnChanges(changes): void {
|
||||||
|
if (changes.cursorPosition && changes.cursorPosition.currentValue) {
|
||||||
|
let x = changes.cursorPosition.currentValue.x + 10;
|
||||||
|
let y = changes.cursorPosition.currentValue.y + 10;
|
||||||
|
if (this.tooltipElement) {
|
||||||
|
const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect();
|
||||||
|
const parentBounds = this.tooltipElement.nativeElement.offsetParent.getBoundingClientRect();
|
||||||
|
if ((parentBounds.left + x + elementBounds.width) > parentBounds.right) {
|
||||||
|
x = Math.max(0, parentBounds.width - elementBounds.width - 10);
|
||||||
|
}
|
||||||
|
if (y + elementBounds.height > parentBounds.height) {
|
||||||
|
y = y - elementBounds.height - 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.tooltipPosition = { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.tx) {
|
||||||
|
const tx = changes.tx.currentValue || {};
|
||||||
|
this.txid = tx.txid || '';
|
||||||
|
this.fee = tx.fee || 0;
|
||||||
|
this.value = tx.value || 0;
|
||||||
|
this.vsize = tx.vsize || 1;
|
||||||
|
this.feeRate = this.fee / this.vsize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -40,10 +40,11 @@
|
|||||||
|
|
||||||
<div class="clearfix"></div>
|
<div class="clearfix"></div>
|
||||||
|
|
||||||
<ng-template [ngIf]="!isLoadingBlock && !error">
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="row">
|
<div class="box" *ngIf="!error">
|
||||||
|
<div class="row">
|
||||||
|
<ng-template [ngIf]="!isLoadingBlock">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<table class="table table-borderless table-striped">
|
<table class="table table-borderless table-striped">
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -68,73 +69,192 @@
|
|||||||
<td i18n="block.weight">Weight</td>
|
<td i18n="block.weight">Weight</td>
|
||||||
<td [innerHTML]="'‎' + (block.weight | wuBytes: 2)"></td>
|
<td [innerHTML]="'‎' + (block.weight | wuBytes: 2)"></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<ng-template [ngIf]="webGlEnabled">
|
||||||
|
<tr *ngIf="block?.extras?.medianFee != undefined">
|
||||||
|
<td class="td-width" i18n="block.median-fee">Median fee</td>
|
||||||
|
<td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.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>
|
||||||
|
<ng-template [ngIf]="fees !== undefined" [ngIfElse]="loadingFees">
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
|
||||||
|
<td *ngIf="network !== 'liquid' && network !== 'liquidtestnet'; else liquidTotalFees">
|
||||||
|
<app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-fiat [value]="block.extras.totalFees" digitsInfo="1.0-0"></app-fiat>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<ng-template #liquidTotalFees>
|
||||||
|
<td>
|
||||||
|
<app-amount [satoshis]="fees * 100000000" digitsInfo="1.2-2" [noFiat]="true"></app-amount> <app-fiat
|
||||||
|
[value]="fees * 100000000" digitsInfo="1.2-2"></app-fiat>
|
||||||
|
</td>
|
||||||
|
</ng-template>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
||||||
|
<td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td>
|
||||||
|
<td>
|
||||||
|
<app-amount [satoshis]="block.extras.reward" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-fiat [value]="(blockSubsidy + fees) * 100000000" digitsInfo="1.0-0"></app-fiat>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #loadingFees>
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
|
||||||
|
<td style="width: 75%;"><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
||||||
|
<td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td>
|
||||||
|
<td><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
||||||
|
<td i18n="block.miner">Miner</td>
|
||||||
|
<td *ngIf="stateService.env.MINING_DASHBOARD">
|
||||||
|
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge"
|
||||||
|
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
|
||||||
|
{{ block.extras.pool.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'">
|
||||||
|
<span placement="bottom" class="badge"
|
||||||
|
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
|
||||||
|
{{ block.extras.pool.name }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [ngIf]="isLoadingBlock">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<table class="table table-borderless table-striped">
|
<table class="table table-borderless table-striped">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngIf="block?.extras?.medianFee != undefined">
|
<tr>
|
||||||
<td class="td-width" i18n="block.median-fee">Median fee</td>
|
<td class="td-width" colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
<td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.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>
|
||||||
<ng-template [ngIf]="fees !== undefined" [ngIfElse]="loadingFees">
|
<tr>
|
||||||
|
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
<ng-template [ngIf]="webGlEnabled">
|
||||||
<tr>
|
<tr>
|
||||||
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
|
<td class="td-width" colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
<td *ngIf="network !== 'liquid' && network !== 'liquidtestnet'; else liquidTotalFees">
|
|
||||||
<app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
|
||||||
<span class="fiat">
|
|
||||||
<app-fiat [value]="block.extras.totalFees" digitsInfo="1.0-0"></app-fiat>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<ng-template #liquidTotalFees>
|
|
||||||
<td>
|
|
||||||
<app-amount [satoshis]="fees * 100000000" digitsInfo="1.2-2" [noFiat]="true"></app-amount> <app-fiat
|
|
||||||
[value]="fees * 100000000" digitsInfo="1.2-2"></app-fiat>
|
|
||||||
</td>
|
|
||||||
</ng-template>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
<tr>
|
||||||
<td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td>
|
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
<td>
|
</tr>
|
||||||
<app-amount [satoshis]="block.extras.reward" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
<tr>
|
||||||
<span class="fiat">
|
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
<app-fiat [value]="(blockSubsidy + fees) * 100000000" digitsInfo="1.0-0"></app-fiat>
|
</tr>
|
||||||
</span>
|
<tr>
|
||||||
</td>
|
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #loadingFees>
|
|
||||||
<tr>
|
|
||||||
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
|
|
||||||
<td style="width: 75%;"><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
|
||||||
<td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td>
|
|
||||||
<td><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
|
||||||
<td i18n="block.miner">Miner</td>
|
|
||||||
<td *ngIf="stateService.env.MINING_DASHBOARD">
|
|
||||||
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge"
|
|
||||||
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
|
|
||||||
{{ block.extras.pool.name }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'">
|
|
||||||
<span placement="bottom" class="badge"
|
|
||||||
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
|
|
||||||
{{ block.extras.pool.name }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<div class="col-sm" *ngIf="!webGlEnabled">
|
||||||
|
<table class="table table-borderless table-striped" *ngIf="!isLoadingBlock">
|
||||||
|
<tbody>
|
||||||
|
<tr *ngIf="block?.extras?.medianFee != undefined">
|
||||||
|
<td class="td-width" i18n="block.median-fee">Median fee</td>
|
||||||
|
<td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.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>
|
||||||
|
<ng-template [ngIf]="fees !== undefined" [ngIfElse]="loadingFees">
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
|
||||||
|
<td *ngIf="network !== 'liquid' && network !== 'liquidtestnet'; else liquidTotalFees">
|
||||||
|
<app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-fiat [value]="block.extras.totalFees" digitsInfo="1.0-0"></app-fiat>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<ng-template #liquidTotalFees>
|
||||||
|
<td>
|
||||||
|
<app-amount [satoshis]="fees * 100000000" digitsInfo="1.2-2" [noFiat]="true"></app-amount> <app-fiat
|
||||||
|
[value]="fees * 100000000" digitsInfo="1.2-2"></app-fiat>
|
||||||
|
</td>
|
||||||
|
</ng-template>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
||||||
|
<td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td>
|
||||||
|
<td>
|
||||||
|
<app-amount [satoshis]="block.extras.reward" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-fiat [value]="(blockSubsidy + fees) * 100000000" digitsInfo="1.0-0"></app-fiat>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #loadingFees>
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
|
||||||
|
<td style="width: 75%;"><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
||||||
|
<td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td>
|
||||||
|
<td><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
||||||
|
<td i18n="block.miner">Miner</td>
|
||||||
|
<td *ngIf="stateService.env.MINING_DASHBOARD">
|
||||||
|
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge"
|
||||||
|
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
|
||||||
|
{{ block.extras.pool.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'">
|
||||||
|
<span placement="bottom" class="badge"
|
||||||
|
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
|
||||||
|
{{ block.extras.pool.name }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table class="table table-borderless table-striped" *ngIf="isLoadingBlock">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="td-width" colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm chart-container" *ngIf="webGlEnabled">
|
||||||
|
<app-block-overview-graph
|
||||||
|
#blockGraph
|
||||||
|
[isLoading]="isLoadingOverview"
|
||||||
|
[resolution]="75"
|
||||||
|
[blockLimit]="stateService.blockVSize"
|
||||||
|
[orientation]="'top'"
|
||||||
|
[flip]="false"
|
||||||
|
(txClickEvent)="onTxClick($event)"
|
||||||
|
></app-block-overview-graph>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-template [ngIf]="!isLoadingBlock && !error">
|
||||||
<div [hidden]="!showDetails" id="details">
|
<div [hidden]="!showDetails" id="details">
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
@ -223,63 +343,17 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<span class="skeleton-loader"></span>
|
<span class="skeleton-loader"></span>
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<span class="skeleton-loader"></span>
|
<span class="skeleton-loader"></span>
|
||||||
<span class="skeleton-loader"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ngb-pagination class="pagination-container float-right" [collectionSize]="block.tx_count" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
|
<ngb-pagination class="pagination-container float-right" [collectionSize]="block.tx_count" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template [ngIf]="isLoadingBlock && !error">
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm">
|
|
||||||
<table class="table table-borderless table-striped">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="td-width" colspan="2"><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2"><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2"><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2"><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm">
|
|
||||||
<table class="table table-borderless table-striped">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="td-width" colspan="2"><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2"><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2"><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2"><span class="skeleton-loader"></span></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template [ngIf]="error">
|
<ng-template [ngIf]="error">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span i18n="error.general-loading-data">Error loading data.</span>
|
<span i18n="error.general-loading-data">Error loading data.</span>
|
||||||
|
@ -148,3 +148,10 @@ h1 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-container{
|
||||||
|
margin: 20px auto;
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,15 +2,16 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/co
|
|||||||
import { Location } from '@angular/common';
|
import { Location } from '@angular/common';
|
||||||
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
|
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
|
||||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||||
import { switchMap, tap, debounceTime, catchError, map } from 'rxjs/operators';
|
import { switchMap, tap, debounceTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators';
|
||||||
import { Transaction, Vout } from '../../interfaces/electrs.interface';
|
import { Transaction, Vout } from '../../interfaces/electrs.interface';
|
||||||
import { Observable, of, Subscription } from 'rxjs';
|
import { Observable, of, Subscription } from 'rxjs';
|
||||||
import { StateService } from '../../services/state.service';
|
import { StateService } from '../../services/state.service';
|
||||||
import { SeoService } from 'src/app/services/seo.service';
|
import { SeoService } from 'src/app/services/seo.service';
|
||||||
import { WebsocketService } from 'src/app/services/websocket.service';
|
import { WebsocketService } from 'src/app/services/websocket.service';
|
||||||
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
||||||
import { BlockExtended } from 'src/app/interfaces/node-api.interface';
|
import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface';
|
||||||
import { ApiService } from 'src/app/services/api.service';
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
|
import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-block',
|
selector: 'app-block',
|
||||||
@ -21,6 +22,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
network = '';
|
network = '';
|
||||||
block: BlockExtended;
|
block: BlockExtended;
|
||||||
blockHeight: number;
|
blockHeight: number;
|
||||||
|
lastBlockHeight: number;
|
||||||
nextBlockHeight: number;
|
nextBlockHeight: number;
|
||||||
blockHash: string;
|
blockHash: string;
|
||||||
isLoadingBlock = true;
|
isLoadingBlock = true;
|
||||||
@ -28,6 +30,10 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
latestBlocks: BlockExtended[] = [];
|
latestBlocks: BlockExtended[] = [];
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
isLoadingTransactions = true;
|
isLoadingTransactions = true;
|
||||||
|
strippedTransactions: TransactionStripped[];
|
||||||
|
overviewTransitionDirection: string;
|
||||||
|
isLoadingOverview = true;
|
||||||
|
isAwaitingOverview = true;
|
||||||
error: any;
|
error: any;
|
||||||
blockSubsidy: number;
|
blockSubsidy: number;
|
||||||
fees: number;
|
fees: number;
|
||||||
@ -39,13 +45,18 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
showPreviousBlocklink = true;
|
showPreviousBlocklink = true;
|
||||||
showNextBlocklink = true;
|
showNextBlocklink = true;
|
||||||
transactionsError: any = null;
|
transactionsError: any = null;
|
||||||
|
overviewError: any = null;
|
||||||
|
webGlEnabled = true;
|
||||||
|
|
||||||
subscription: Subscription;
|
transactionSubscription: Subscription;
|
||||||
|
overviewSubscription: Subscription;
|
||||||
keyNavigationSubscription: Subscription;
|
keyNavigationSubscription: Subscription;
|
||||||
blocksSubscription: Subscription;
|
blocksSubscription: Subscription;
|
||||||
networkChangedSubscription: Subscription;
|
networkChangedSubscription: Subscription;
|
||||||
queryParamsSubscription: Subscription;
|
queryParamsSubscription: Subscription;
|
||||||
|
|
||||||
|
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private location: Location,
|
private location: Location,
|
||||||
@ -56,7 +67,9 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
private websocketService: WebsocketService,
|
private websocketService: WebsocketService,
|
||||||
private relativeUrlPipe: RelativeUrlPipe,
|
private relativeUrlPipe: RelativeUrlPipe,
|
||||||
private apiService: ApiService
|
private apiService: ApiService
|
||||||
) { }
|
) {
|
||||||
|
this.webGlEnabled = detectWebGL();
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.websocketService.want(['blocks', 'mempool-blocks']);
|
this.websocketService.want(['blocks', 'mempool-blocks']);
|
||||||
@ -85,7 +98,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.subscription = this.route.paramMap.pipe(
|
const block$ = this.route.paramMap.pipe(
|
||||||
switchMap((params: ParamMap) => {
|
switchMap((params: ParamMap) => {
|
||||||
const blockHash: string = params.get('id') || '';
|
const blockHash: string = params.get('id') || '';
|
||||||
this.block = undefined;
|
this.block = undefined;
|
||||||
@ -141,6 +154,8 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
tap((block: BlockExtended) => {
|
tap((block: BlockExtended) => {
|
||||||
this.block = block;
|
this.block = block;
|
||||||
this.blockHeight = block.height;
|
this.blockHeight = block.height;
|
||||||
|
const direction = (this.lastBlockHeight < this.blockHeight) ? 'right' : 'left';
|
||||||
|
this.lastBlockHeight = this.blockHeight;
|
||||||
this.nextBlockHeight = block.height + 1;
|
this.nextBlockHeight = block.height + 1;
|
||||||
this.setNextAndPreviousBlockLink();
|
this.setNextAndPreviousBlockLink();
|
||||||
|
|
||||||
@ -154,8 +169,17 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
this.isLoadingTransactions = true;
|
this.isLoadingTransactions = true;
|
||||||
this.transactions = null;
|
this.transactions = null;
|
||||||
this.transactionsError = null;
|
this.transactionsError = null;
|
||||||
|
this.isLoadingOverview = true;
|
||||||
|
this.isAwaitingOverview = true;
|
||||||
|
this.overviewError = true;
|
||||||
|
if (this.blockGraph) {
|
||||||
|
this.blockGraph.exit(direction);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
debounceTime(300),
|
debounceTime(300),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
this.transactionSubscription = block$.pipe(
|
||||||
switchMap((block) => this.electrsApiService.getBlockTransactions$(block.id)
|
switchMap((block) => this.electrsApiService.getBlockTransactions$(block.id)
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
@ -170,10 +194,50 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.transactions = transactions;
|
this.transactions = transactions;
|
||||||
this.isLoadingTransactions = false;
|
this.isLoadingTransactions = false;
|
||||||
|
|
||||||
|
if (!this.isAwaitingOverview && this.blockGraph && this.strippedTransactions && this.overviewTransitionDirection) {
|
||||||
|
this.isLoadingOverview = false;
|
||||||
|
this.blockGraph.replace(this.strippedTransactions, this.overviewTransitionDirection, false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.error = error;
|
this.error = error;
|
||||||
this.isLoadingBlock = false;
|
this.isLoadingBlock = false;
|
||||||
|
this.isLoadingOverview = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.overviewSubscription = block$.pipe(
|
||||||
|
startWith(null),
|
||||||
|
pairwise(),
|
||||||
|
switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id)
|
||||||
|
.pipe(
|
||||||
|
catchError((err) => {
|
||||||
|
this.overviewError = err;
|
||||||
|
return of([]);
|
||||||
|
}),
|
||||||
|
switchMap((transactions) => {
|
||||||
|
if (prevBlock) {
|
||||||
|
return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' });
|
||||||
|
} else {
|
||||||
|
return of({ transactions, direction: 'down' });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => {
|
||||||
|
this.isAwaitingOverview = false;
|
||||||
|
this.strippedTransactions = transactions;
|
||||||
|
this.overviewTransitionDirection = direction;
|
||||||
|
if (!this.isLoadingTransactions && this.blockGraph) {
|
||||||
|
this.isLoadingOverview = false;
|
||||||
|
this.blockGraph.replace(this.strippedTransactions, this.overviewTransitionDirection, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
this.error = error;
|
||||||
|
this.isLoadingOverview = false;
|
||||||
|
this.isAwaitingOverview = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.networkChangedSubscription = this.stateService.networkChanged$
|
this.networkChangedSubscription = this.stateService.networkChanged$
|
||||||
@ -203,7 +267,8 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.stateService.markBlock$.next({});
|
this.stateService.markBlock$.next({});
|
||||||
this.subscription.unsubscribe();
|
this.transactionSubscription.unsubscribe();
|
||||||
|
this.overviewSubscription.unsubscribe();
|
||||||
this.keyNavigationSubscription.unsubscribe();
|
this.keyNavigationSubscription.unsubscribe();
|
||||||
this.blocksSubscription.unsubscribe();
|
this.blocksSubscription.unsubscribe();
|
||||||
this.networkChangedSubscription.unsubscribe();
|
this.networkChangedSubscription.unsubscribe();
|
||||||
@ -302,4 +367,15 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTxClick(event: TransactionStripped): void {
|
||||||
|
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
|
||||||
|
this.router.navigate([url]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectWebGL() {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||||
|
return (gl && gl instanceof WebGLRenderingContext);
|
||||||
}
|
}
|
||||||
|
@ -1,684 +0,0 @@
|
|||||||
import { FastVertexArray } from './fast-vertex-array'
|
|
||||||
import TxView from './tx-view'
|
|
||||||
import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
|
||||||
import { Position, Square } from './sprite-types'
|
|
||||||
|
|
||||||
export default class BlockScene {
|
|
||||||
scene: { count: number, offset: { x: number, y: number}};
|
|
||||||
vertexArray: FastVertexArray;
|
|
||||||
txs: { [key: string]: TxView };
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
gridWidth: number;
|
|
||||||
gridHeight: number;
|
|
||||||
gridSize: number;
|
|
||||||
vbytesPerUnit: number;
|
|
||||||
unitPadding: number;
|
|
||||||
unitWidth: number;
|
|
||||||
initialised: boolean;
|
|
||||||
layout: BlockLayout;
|
|
||||||
dirty: boolean;
|
|
||||||
|
|
||||||
constructor ({ width, height, resolution, blockLimit, vertexArray }: { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray }) {
|
|
||||||
this.init({ width, height, resolution, blockLimit, vertexArray })
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy (): void {
|
|
||||||
Object.values(this.txs).forEach(tx => tx.destroy())
|
|
||||||
}
|
|
||||||
|
|
||||||
resize({ width = this.width, height = this.height }: { width?: number, height?: number}): void {
|
|
||||||
this.width = width;
|
|
||||||
this.height = height;
|
|
||||||
this.gridSize = this.width / this.gridWidth;
|
|
||||||
this.unitPadding = width / 500;
|
|
||||||
this.unitWidth = this.gridSize - (this.unitPadding * 2);
|
|
||||||
|
|
||||||
this.dirty = true;
|
|
||||||
if (this.initialised && this.scene) {
|
|
||||||
this.updateAll(performance.now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate new block entering scene
|
|
||||||
enter (txs: TransactionStripped[], direction) {
|
|
||||||
this.replace(txs, direction)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate block leaving scene
|
|
||||||
exit (direction: string): void {
|
|
||||||
const startTime = performance.now()
|
|
||||||
const removed = this.removeBatch(Object.keys(this.txs), startTime, direction)
|
|
||||||
|
|
||||||
// clean up sprites
|
|
||||||
setTimeout(() => {
|
|
||||||
removed.forEach(tx => {
|
|
||||||
tx.destroy()
|
|
||||||
})
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset layout and replace with new set of transactions
|
|
||||||
replace (txs: TransactionStripped[], direction: string = 'left'): void {
|
|
||||||
const startTime = performance.now()
|
|
||||||
const nextIds = {}
|
|
||||||
const remove = []
|
|
||||||
txs.forEach(tx => {
|
|
||||||
nextIds[tx.txid] = true
|
|
||||||
})
|
|
||||||
Object.keys(this.txs).forEach(txid => {
|
|
||||||
if (!nextIds[txid]) remove.push(txid)
|
|
||||||
})
|
|
||||||
txs.forEach(tx => {
|
|
||||||
if (!this.txs[tx.txid]) this.txs[tx.txid] = new TxView(tx, this.vertexArray)
|
|
||||||
})
|
|
||||||
|
|
||||||
const removed = this.removeBatch(remove, startTime, direction)
|
|
||||||
|
|
||||||
// clean up sprites
|
|
||||||
setTimeout(() => {
|
|
||||||
removed.forEach(tx => {
|
|
||||||
tx.destroy()
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight })
|
|
||||||
|
|
||||||
Object.values(this.txs).sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => {
|
|
||||||
this.place(tx)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.updateAll(startTime, direction)
|
|
||||||
}
|
|
||||||
|
|
||||||
update (add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void {
|
|
||||||
const startTime = performance.now()
|
|
||||||
const removed = this.removeBatch(remove, startTime, direction)
|
|
||||||
|
|
||||||
// clean up sprites
|
|
||||||
setTimeout(() => {
|
|
||||||
removed.forEach(tx => {
|
|
||||||
tx.destroy()
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
if (resetLayout) {
|
|
||||||
add.forEach(tx => {
|
|
||||||
if (!this.txs[tx.txid]) this.txs[tx.txid] = new TxView(tx, this.vertexArray)
|
|
||||||
})
|
|
||||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight })
|
|
||||||
Object.values(this.txs).sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => {
|
|
||||||
this.place(tx)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// try to insert new txs directly
|
|
||||||
const remaining = []
|
|
||||||
add.map(tx => new TxView(tx, this.vertexArray)).sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => {
|
|
||||||
if (!this.tryInsertByFee(tx)) {
|
|
||||||
remaining.push(tx)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.placeBatch(remaining)
|
|
||||||
this.layout.applyGravity()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateAll(startTime, direction)
|
|
||||||
}
|
|
||||||
|
|
||||||
//return the tx at this screen position, if any
|
|
||||||
getTxAt (position: Position): TxView | void {
|
|
||||||
if (this.layout) {
|
|
||||||
const gridPosition = this.screenToGrid(position)
|
|
||||||
return this.layout.getTx(gridPosition)
|
|
||||||
} else return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private init ({ width, height, resolution, blockLimit, vertexArray }: { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray }): void {
|
|
||||||
this.vertexArray = vertexArray
|
|
||||||
|
|
||||||
this.scene = {
|
|
||||||
count: 0,
|
|
||||||
offset: {
|
|
||||||
x: 0,
|
|
||||||
y: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the scale of the visualization (with a 5% margin)
|
|
||||||
this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.05, 2)
|
|
||||||
this.gridWidth = resolution
|
|
||||||
this.gridHeight = resolution
|
|
||||||
this.resize({ width, height })
|
|
||||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight })
|
|
||||||
|
|
||||||
this.txs = {}
|
|
||||||
|
|
||||||
this.initialised = true
|
|
||||||
this.dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private insert (tx: TxView, startTime: number, direction: string = 'left'): void {
|
|
||||||
this.txs[tx.txid] = tx
|
|
||||||
this.place(tx)
|
|
||||||
this.updateTx(tx, startTime, direction)
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateTx (tx: TxView, startTime: number, direction: string = 'left'): void {
|
|
||||||
if (tx.dirty || this.dirty) {
|
|
||||||
this.saveGridToScreenPosition(tx)
|
|
||||||
this.setTxOnScreen(tx, startTime, direction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setTxOnScreen (tx: TxView, startTime: number, direction: string = 'left'): void {
|
|
||||||
if (!tx.initialised) {
|
|
||||||
const txColor = tx.getColor()
|
|
||||||
tx.update({
|
|
||||||
display: {
|
|
||||||
position: {
|
|
||||||
x: tx.screenPosition.x + (direction == 'right' ? -this.width : this.width) * 1.4,
|
|
||||||
y: tx.screenPosition.y,
|
|
||||||
s: tx.screenPosition.s
|
|
||||||
},
|
|
||||||
color: txColor,
|
|
||||||
},
|
|
||||||
start: startTime,
|
|
||||||
delay: 0,
|
|
||||||
})
|
|
||||||
tx.update({
|
|
||||||
display: {
|
|
||||||
position: tx.screenPosition,
|
|
||||||
color: txColor
|
|
||||||
},
|
|
||||||
duration: 1000,
|
|
||||||
start: startTime,
|
|
||||||
delay: 50,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
tx.update({
|
|
||||||
display: {
|
|
||||||
position: tx.screenPosition
|
|
||||||
},
|
|
||||||
duration: 1000,
|
|
||||||
minDuration: 500,
|
|
||||||
start: startTime,
|
|
||||||
delay: 50,
|
|
||||||
adjust: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateAll (startTime: number, direction: string = 'left'): void {
|
|
||||||
this.scene.count = 0
|
|
||||||
const ids = this.getTxList()
|
|
||||||
startTime = startTime || performance.now()
|
|
||||||
for (let i = 0; i < ids.length; i++) {
|
|
||||||
this.updateTx(this.txs[ids[i]], startTime, direction)
|
|
||||||
}
|
|
||||||
this.dirty = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private remove (id: string, startTime: number, direction: string = 'left'): TxView | void {
|
|
||||||
const tx = this.txs[id]
|
|
||||||
if (tx) {
|
|
||||||
this.layout.remove(tx)
|
|
||||||
tx.update({
|
|
||||||
display: {
|
|
||||||
position: {
|
|
||||||
x: tx.screenPosition.x + (direction == 'right' ? this.width : -this.width) * 1.4,
|
|
||||||
y: this.txs[id].screenPosition.y,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
duration: 1000,
|
|
||||||
start: startTime,
|
|
||||||
delay: 50
|
|
||||||
})
|
|
||||||
}
|
|
||||||
delete this.txs[id]
|
|
||||||
return tx
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTxList (): string[] {
|
|
||||||
return Object.keys(this.txs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveGridToScreenPosition (tx: TxView): void {
|
|
||||||
tx.screenPosition = this.gridToScreen(tx.gridPosition)
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert grid coordinates to screen coordinates
|
|
||||||
private gridToScreen (position: Square | void): Square {
|
|
||||||
if (position) {
|
|
||||||
const slotSize = (position.s * this.gridSize)
|
|
||||||
const squareSize = slotSize - (this.unitPadding * 2)
|
|
||||||
|
|
||||||
// The grid is laid out notionally left-to-right, bottom-to-top
|
|
||||||
// So we rotate 90deg counterclockwise then flip the y axis
|
|
||||||
//
|
|
||||||
// grid screen
|
|
||||||
// ________ ________ ________
|
|
||||||
// | | | b| | a|
|
|
||||||
// | | rotate | | flip | c |
|
|
||||||
// | c | --> | c | --> | |
|
|
||||||
// |a______b| |_______a| |_______b|
|
|
||||||
return {
|
|
||||||
x: this.width + (this.unitPadding * 2) - (this.gridSize * position.y) - slotSize,
|
|
||||||
y: this.height - ((this.gridSize * position.x) + (slotSize - this.unitPadding)),
|
|
||||||
s: squareSize
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return { x: 0, y: 0, s: 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
screenToGrid (position: Position): Position {
|
|
||||||
const grid = {
|
|
||||||
x: Math.floor((position.y - this.unitPadding) / this.gridSize),
|
|
||||||
y: Math.floor((this.width + (this.unitPadding * 2) - position.x) / this.gridSize)
|
|
||||||
}
|
|
||||||
return grid
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculates and returns the size of the tx in multiples of the grid size
|
|
||||||
private txSize (tx: TxView): number {
|
|
||||||
let scale = Math.max(1,Math.round(Math.sqrt(tx.vsize / this.vbytesPerUnit)))
|
|
||||||
return Math.min(this.gridWidth, Math.max(1, scale)) // bound between 1 and the max displayable size (just in case!)
|
|
||||||
}
|
|
||||||
|
|
||||||
private place (tx: TxView): void {
|
|
||||||
const size = this.txSize(tx)
|
|
||||||
this.layout.insert(tx, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryInsertByFee (tx: TxView): boolean {
|
|
||||||
const size = this.txSize(tx)
|
|
||||||
const position = this.layout.tryInsertByFee(tx, size)
|
|
||||||
if (position) {
|
|
||||||
this.txs[tx.txid] = tx
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a list of transactions to the layout,
|
|
||||||
// keeping everything approximately sorted by feerate.
|
|
||||||
private placeBatch (txs: TxView[]): void {
|
|
||||||
if (txs.length) {
|
|
||||||
// grab the new tx with the highest fee rate
|
|
||||||
txs = txs.sort((a,b) => { return b.feerate - a.feerate })
|
|
||||||
let i = 0
|
|
||||||
let maxSize = txs.reduce((max, tx) => {
|
|
||||||
return Math.max(this.txSize(tx), max)
|
|
||||||
}, 1) * 2
|
|
||||||
|
|
||||||
// find a reasonable place for it in the layout
|
|
||||||
const root = this.layout.getReplacementRoot(txs[0].feerate, maxSize)
|
|
||||||
|
|
||||||
// extract a sub tree of transactions from the layout, rooted at that point
|
|
||||||
const popped = this.layout.popTree(root.x, root.y, maxSize)
|
|
||||||
// combine those with the new transactions and sort
|
|
||||||
txs = txs.concat(popped)
|
|
||||||
txs = txs.sort((a,b) => { return b.feerate - a.feerate })
|
|
||||||
|
|
||||||
// insert everything back into the layout
|
|
||||||
txs.forEach(tx => {
|
|
||||||
this.txs[tx.txid] = tx
|
|
||||||
this.place(tx)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeBatch (ids: string[], startTime: number, direction: string = 'left'): TxView[] {
|
|
||||||
if (!startTime) startTime = performance.now()
|
|
||||||
return ids.map(id => {
|
|
||||||
return this.remove(id, startTime, direction)
|
|
||||||
}).filter(tx => tx != null) as TxView[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Slot {
|
|
||||||
l: number
|
|
||||||
r: number
|
|
||||||
w: number
|
|
||||||
|
|
||||||
constructor (l: number, r: number) {
|
|
||||||
this.l = l
|
|
||||||
this.r = r
|
|
||||||
this.w = r - l
|
|
||||||
}
|
|
||||||
|
|
||||||
intersects (slot: Slot): boolean {
|
|
||||||
return !((slot.r <= this.l) || (slot.l >= this.r))
|
|
||||||
}
|
|
||||||
|
|
||||||
subtract (slot: Slot): Slot[] | void {
|
|
||||||
if (this.intersects(slot)) {
|
|
||||||
// from middle
|
|
||||||
if (slot.l > this.l && slot.r < this.r) {
|
|
||||||
return [
|
|
||||||
new Slot(this.l, slot.l),
|
|
||||||
new Slot(slot.r, this.r)
|
|
||||||
]
|
|
||||||
} // totally covered
|
|
||||||
else if (slot.l <= this.l && slot.r >= this.r) {
|
|
||||||
return []
|
|
||||||
} // from left side
|
|
||||||
else if (slot.l <= this.l) {
|
|
||||||
if (slot.r == this.r) return []
|
|
||||||
else return [new Slot(slot.r, this.r)]
|
|
||||||
} // from right side
|
|
||||||
else if (slot.r >= this.r) {
|
|
||||||
if (slot.l == this.l) return []
|
|
||||||
else return [new Slot(this.l, slot.l)]
|
|
||||||
}
|
|
||||||
} else return [this]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TxSlot extends Slot {
|
|
||||||
tx: TxView
|
|
||||||
|
|
||||||
constructor (l: number, r: number, tx: TxView) {
|
|
||||||
super(l, r)
|
|
||||||
this.tx = tx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Row {
|
|
||||||
y: number
|
|
||||||
w: number
|
|
||||||
filled: TxSlot[]
|
|
||||||
slots: Slot[]
|
|
||||||
|
|
||||||
|
|
||||||
constructor (y: number, width: number) {
|
|
||||||
this.y = y
|
|
||||||
this.w = width
|
|
||||||
this.filled = []
|
|
||||||
this.slots = [new Slot(0, this.w)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert a transaction w/ given width into row starting at position x
|
|
||||||
insert (x: number, w: number, tx: TxView): void {
|
|
||||||
const newSlot = new TxSlot(x, x + w, tx)
|
|
||||||
// insert into filled list
|
|
||||||
let index = this.filled.findIndex((slot) => { return slot.l >= newSlot.r })
|
|
||||||
if (index < 0) index = this.filled.length
|
|
||||||
this.filled.splice(index || 0, 0, newSlot)
|
|
||||||
// subtract from overlapping slots
|
|
||||||
for (let i = 0; i < this.slots.length; i++) {
|
|
||||||
if (newSlot.intersects(this.slots[i])) {
|
|
||||||
const diff = this.slots[i].subtract(newSlot)
|
|
||||||
if (diff) {
|
|
||||||
this.slots.splice(i, 1, ...diff)
|
|
||||||
i += diff.length - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remove (x: number, w: number): void {
|
|
||||||
const txIndex = this.filled.findIndex((slot) => { return slot.l == x })
|
|
||||||
this.filled.splice(txIndex, 1)
|
|
||||||
|
|
||||||
const newSlot = new Slot(x, x + w)
|
|
||||||
let slotIndex = this.slots.findIndex((slot) => { return slot.l >= newSlot.r })
|
|
||||||
if (slotIndex < 0) slotIndex = this.slots.length
|
|
||||||
this.slots.splice(slotIndex || 0, 0, newSlot)
|
|
||||||
this.normalize()
|
|
||||||
}
|
|
||||||
|
|
||||||
// merge any contiguous empty slots
|
|
||||||
private normalize (): void {
|
|
||||||
for (let i = 0; i < this.slots.length - 1; i++) {
|
|
||||||
if (this.slots[i].r == this.slots[i+1].l) {
|
|
||||||
this.slots[i].r = this.slots[i+1].r
|
|
||||||
this.slots[i].w += this.slots[i+1].w
|
|
||||||
this.slots.splice(i+1, 1)
|
|
||||||
i--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
txAt (x: number): TxView | void {
|
|
||||||
let i = 0
|
|
||||||
while (i < this.filled.length && this.filled[i].l <= x) {
|
|
||||||
if (this.filled[i].l <= x && this.filled[i].r > x) return this.filled[i].tx
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getSlotsBetween (left: number, right: number): TxSlot[] {
|
|
||||||
const range = new Slot(left, right)
|
|
||||||
return this.filled.filter(slot => {
|
|
||||||
return slot.intersects(range)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
slotAt (x: number): Slot | void {
|
|
||||||
let i = 0
|
|
||||||
while (i < this.slots.length && this.slots[i].l <= x) {
|
|
||||||
if (this.slots[i].l <= x && this.slots[i].r > x) return this.slots[i]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getAvgFeerate (): number {
|
|
||||||
let count = 0
|
|
||||||
let total = 0
|
|
||||||
this.filled.forEach(slot => {
|
|
||||||
if (slot.tx) {
|
|
||||||
count += slot.w
|
|
||||||
total += (slot.tx.feerate * slot.w)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return total / count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class BlockLayout {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
rows: Row[];
|
|
||||||
txPositions: { [key: string]: Square }
|
|
||||||
txs: { [key: string]: TxView }
|
|
||||||
|
|
||||||
constructor ({ width, height } : { width: number, height: number }) {
|
|
||||||
this.width = width
|
|
||||||
this.height = height
|
|
||||||
this.rows = [new Row(0, this.width)]
|
|
||||||
this.txPositions = {}
|
|
||||||
this.txs = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
getRow (position: Square): Row {
|
|
||||||
return this.rows[position.y]
|
|
||||||
}
|
|
||||||
|
|
||||||
getTx (position: Square): TxView | void {
|
|
||||||
if (this.getRow(position)) {
|
|
||||||
return this.getRow(position).txAt(position.x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addRow (): void {
|
|
||||||
this.rows.push(new Row(this.rows.length, this.width))
|
|
||||||
}
|
|
||||||
|
|
||||||
remove (tx: TxView) {
|
|
||||||
const position = this.txPositions[tx.txid]
|
|
||||||
if (position) {
|
|
||||||
for (let y = position.y; y < position.y + position.s && y < this.rows.length; y++) {
|
|
||||||
this.rows[y].remove(position.x, position.s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete this.txPositions[tx.txid]
|
|
||||||
delete this.txs[tx.txid]
|
|
||||||
}
|
|
||||||
|
|
||||||
insert (tx: TxView, width: number): Square {
|
|
||||||
const fit = this.fit(tx, width)
|
|
||||||
|
|
||||||
// insert the tx into rows at that position
|
|
||||||
for (let y = fit.y; y < fit.y + width; y++) {
|
|
||||||
if (y >= this.rows.length) this.addRow()
|
|
||||||
this.rows[y].insert(fit.x, width, tx)
|
|
||||||
}
|
|
||||||
const position = { x: fit.x, y: fit.y, s: width }
|
|
||||||
this.txPositions[tx.txid] = position
|
|
||||||
this.txs[tx.txid] = tx
|
|
||||||
tx.applyGridPosition(position)
|
|
||||||
return position
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the first slot large enough to hold a transaction of this size
|
|
||||||
fit (tx: TxView, width: number): Square {
|
|
||||||
let fit
|
|
||||||
for (let y = 0; y < this.rows.length && !fit; y++) {
|
|
||||||
fit = this.findFit(0, this.width, y, y, width)
|
|
||||||
}
|
|
||||||
// fall back to placing tx in a new row at the top of the layout
|
|
||||||
if (!fit) {
|
|
||||||
fit = { x: 0, y: this.rows.length }
|
|
||||||
}
|
|
||||||
return fit
|
|
||||||
}
|
|
||||||
|
|
||||||
// recursively check rows to see if there's space for a tx (depth-first)
|
|
||||||
// left/right: initial column boundaries to check
|
|
||||||
// row: current row to check
|
|
||||||
// start: starting row
|
|
||||||
// size: size of space needed
|
|
||||||
findFit (left: number, right: number, row: number, start: number, size: number) : Square {
|
|
||||||
if ((row - start) >= size || row >= this.rows.length) {
|
|
||||||
return { x: left, y: start }
|
|
||||||
}
|
|
||||||
for (let i = 0; i < this.rows[row].slots.length; i++) {
|
|
||||||
const slot = this.rows[row].slots[i]
|
|
||||||
const l = Math.max(left, slot.l)
|
|
||||||
const r = Math.min(right, slot.r)
|
|
||||||
if (r - l >= size) {
|
|
||||||
const fit = this.findFit(l, r, row + 1, start, size)
|
|
||||||
if (fit) return fit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert only if the tx fits into a fee-appropriate position
|
|
||||||
tryInsertByFee (tx: TxView, size: number): Square | void {
|
|
||||||
const fit = this.fit(tx, size)
|
|
||||||
|
|
||||||
if (this.checkRowFees(fit.y, tx.feerate)) {
|
|
||||||
// insert the tx into rows at that position
|
|
||||||
for (let y = fit.y; y < fit.y + size; y++) {
|
|
||||||
if (y >= this.rows.length) this.addRow()
|
|
||||||
this.rows[y].insert(fit.x, size, tx)
|
|
||||||
}
|
|
||||||
const position = { x: fit.x, y: fit.y, s: size }
|
|
||||||
this.txPositions[tx.txid] = position
|
|
||||||
this.txs[tx.txid] = tx
|
|
||||||
tx.applyGridPosition(position)
|
|
||||||
return position
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the first slot with a lower feerate
|
|
||||||
getReplacementRoot (feerate: number, width: number): Square {
|
|
||||||
let slot
|
|
||||||
for (let row = 0; row <= this.rows.length; row++) {
|
|
||||||
if (this.rows[row].slots.length > 0) {
|
|
||||||
return { x: this.rows[row].slots[0].l, y: row }
|
|
||||||
} else {
|
|
||||||
slot = this.rows[row].filled.find(x => {
|
|
||||||
return x.tx.feerate < feerate
|
|
||||||
})
|
|
||||||
if (slot) {
|
|
||||||
return { x: Math.min(slot.l, this.width - width), y: row }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { x: 0, y: this.rows.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove and return all transactions in a subtree of the layout
|
|
||||||
popTree (x: number, y: number, width: number) {
|
|
||||||
const selected: { [key: string]: TxView } = {}
|
|
||||||
let left = x
|
|
||||||
let right = x + width
|
|
||||||
let prevWidth = right - left
|
|
||||||
let prevFee = Infinity
|
|
||||||
// scan rows upwards within a channel bounded by 'left' and 'right'
|
|
||||||
for (let row = y; row < this.rows.length; row++) {
|
|
||||||
let rowMax = 0
|
|
||||||
let slots = this.rows[row].getSlotsBetween(left, right)
|
|
||||||
// check each slot in this row overlapping the search channel
|
|
||||||
slots.forEach(slot => {
|
|
||||||
// select the associated transaction
|
|
||||||
selected[slot.tx.txid] = slot.tx
|
|
||||||
rowMax = Math.max(rowMax, slot.tx.feerate)
|
|
||||||
// widen the search channel to accommodate this slot if necessary
|
|
||||||
if (slot.w > prevWidth) {
|
|
||||||
left = slot.l
|
|
||||||
right = slot.r
|
|
||||||
// if this slot's tx has a higher feerate than the max in the previous row
|
|
||||||
// (i.e. it's out of position)
|
|
||||||
// select all txs overlapping the slot's full width in some rows *below*
|
|
||||||
// to free up space for this tx to sink down to its proper position
|
|
||||||
if (slot.tx.feerate > prevFee) {
|
|
||||||
let count = 0
|
|
||||||
// keep scanning back down until we find a full row of higher-feerate txs
|
|
||||||
for (let echo = row - 1; echo >= 0 && count < slot.w; echo--) {
|
|
||||||
let echoSlots = this.rows[echo].getSlotsBetween(slot.l, slot.r)
|
|
||||||
count = 0
|
|
||||||
echoSlots.forEach(echoSlot => {
|
|
||||||
selected[echoSlot.tx.txid] = echoSlot.tx
|
|
||||||
if (echoSlot.tx.feerate >= slot.tx.feerate) {
|
|
||||||
count += echoSlot.w
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
prevWidth = right - left
|
|
||||||
prevFee = rowMax
|
|
||||||
}
|
|
||||||
|
|
||||||
const txList = Object.values(selected)
|
|
||||||
|
|
||||||
txList.forEach(tx => {
|
|
||||||
this.remove(tx)
|
|
||||||
})
|
|
||||||
return txList
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this row has high enough avg fees
|
|
||||||
// for a tx with this feerate to make sense here
|
|
||||||
checkRowFees (row: number, targetFee: number): boolean {
|
|
||||||
// first row is always fine
|
|
||||||
if (row == 0 || !this.rows[row]) return true
|
|
||||||
return (this.rows[row].getAvgFeerate() > (targetFee * 0.9))
|
|
||||||
}
|
|
||||||
|
|
||||||
// drop any free-floating transactions down into empty spaces
|
|
||||||
applyGravity (): void {
|
|
||||||
Object.entries(this.txPositions).sort(([keyA, posA], [keyB, posB]) => {
|
|
||||||
return posA.y - posB.y || posA.x - posB.x
|
|
||||||
}).forEach(([txid, position]) => {
|
|
||||||
// see how far this transaction can fall
|
|
||||||
let dropTo = position.y
|
|
||||||
while (dropTo > 0 && !this.rows[dropTo - 1].getSlotsBetween(position.x, position.x + position.s).length) {
|
|
||||||
dropTo--;
|
|
||||||
}
|
|
||||||
// if it can fall at all
|
|
||||||
if (dropTo < position.y) {
|
|
||||||
// remove and reinsert in the row we found
|
|
||||||
const tx = this.txs[txid]
|
|
||||||
this.remove(tx)
|
|
||||||
this.insert(tx, position.s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +1,9 @@
|
|||||||
<div class="mempool-block-overview">
|
<app-block-overview-graph
|
||||||
<canvas class="block-overview" [style.width]="cssWidth + 'px'" [style.height]="cssHeight + 'px'" #blockCanvas></canvas>
|
#blockGraph
|
||||||
<div class="loader-wrapper" [class.hidden]="!(isLoading$ | async)">
|
[isLoading]="isLoading$ | async"
|
||||||
<div class="spinner-border ml-3 loading" role="status"></div>
|
[resolution]="75"
|
||||||
</div>
|
[blockLimit]="stateService.blockVSize"
|
||||||
</div>
|
[orientation]="'left'"
|
||||||
|
[flip]="true"
|
||||||
|
(txClickEvent)="onTxClick($event)"
|
||||||
|
></app-block-overview-graph>
|
||||||
|
@ -1,40 +1,25 @@
|
|||||||
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, OnInit,
|
import { Component, ComponentRef, ViewChild, HostListener, Input, Output, EventEmitter,
|
||||||
OnDestroy, OnChanges, ChangeDetectionStrategy, NgZone, AfterViewInit } from '@angular/core';
|
OnDestroy, OnChanges, ChangeDetectionStrategy, AfterViewInit } from '@angular/core';
|
||||||
import { StateService } from 'src/app/services/state.service';
|
import { StateService } from 'src/app/services/state.service';
|
||||||
import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||||
|
import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
|
||||||
import { Subscription, BehaviorSubject, merge, of } from 'rxjs';
|
import { Subscription, BehaviorSubject, merge, of } from 'rxjs';
|
||||||
import { switchMap, filter } from 'rxjs/operators';
|
import { switchMap, filter } from 'rxjs/operators';
|
||||||
import { WebsocketService } from 'src/app/services/websocket.service';
|
import { WebsocketService } from 'src/app/services/websocket.service';
|
||||||
import { FastVertexArray } from './fast-vertex-array';
|
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
||||||
import BlockScene from './block-scene';
|
import { Router } from '@angular/router';
|
||||||
import TxSprite from './tx-sprite';
|
|
||||||
import TxView from './tx-view';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-mempool-block-overview',
|
selector: 'app-mempool-block-overview',
|
||||||
templateUrl: './mempool-block-overview.component.html',
|
templateUrl: './mempool-block-overview.component.html',
|
||||||
styleUrls: ['./mempool-block-overview.component.scss'],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit {
|
export class MempoolBlockOverviewComponent implements OnDestroy, OnChanges, AfterViewInit {
|
||||||
@Input() index: number;
|
@Input() index: number;
|
||||||
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
|
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
|
||||||
|
|
||||||
@ViewChild('blockCanvas')
|
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
|
||||||
canvas: ElementRef<HTMLCanvasElement>;
|
|
||||||
|
|
||||||
gl: WebGLRenderingContext;
|
|
||||||
animationFrameRequest: number;
|
|
||||||
displayWidth: number;
|
|
||||||
displayHeight: number;
|
|
||||||
cssWidth: number;
|
|
||||||
cssHeight: number;
|
|
||||||
shaderProgram: WebGLProgram;
|
|
||||||
vertexArray: FastVertexArray;
|
|
||||||
running: boolean;
|
|
||||||
scene: BlockScene;
|
|
||||||
hoverTx: TxView | void;
|
|
||||||
selectedTx: TxView | void;
|
|
||||||
lastBlockHeight: number;
|
lastBlockHeight: number;
|
||||||
blockIndex: number;
|
blockIndex: number;
|
||||||
isLoading$ = new BehaviorSubject<boolean>(true);
|
isLoading$ = new BehaviorSubject<boolean>(true);
|
||||||
@ -45,12 +30,10 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
|||||||
constructor(
|
constructor(
|
||||||
public stateService: StateService,
|
public stateService: StateService,
|
||||||
private websocketService: WebsocketService,
|
private websocketService: WebsocketService,
|
||||||
readonly ngZone: NgZone,
|
private router: Router,
|
||||||
) {
|
) { }
|
||||||
this.vertexArray = new FastVertexArray(512, TxSprite.dataSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngAfterViewInit(): void {
|
||||||
this.blockSub = merge(
|
this.blockSub = merge(
|
||||||
of(true),
|
of(true),
|
||||||
this.stateService.connectionState$.pipe(filter((state) => state === 2))
|
this.stateService.connectionState$.pipe(filter((state) => state === 2))
|
||||||
@ -64,18 +47,11 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false);
|
|
||||||
this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false);
|
|
||||||
this.gl = this.canvas.nativeElement.getContext('webgl');
|
|
||||||
this.initCanvas();
|
|
||||||
|
|
||||||
this.resizeCanvas();
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnChanges(changes): void {
|
ngOnChanges(changes): void {
|
||||||
if (changes.index) {
|
if (changes.index) {
|
||||||
this.clearBlock(changes.index.currentValue > changes.index.previousValue ? 'right' : 'left');
|
if (this.blockGraph) {
|
||||||
|
this.blockGraph.clear(changes.index.currentValue > changes.index.previousValue ? 'right' : 'left');
|
||||||
|
}
|
||||||
this.isLoading$.next(true);
|
this.isLoading$.next(true);
|
||||||
this.websocketService.startTrackMempoolBlock(changes.index.currentValue);
|
this.websocketService.startTrackMempoolBlock(changes.index.currentValue);
|
||||||
}
|
}
|
||||||
@ -87,26 +63,13 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
|||||||
this.websocketService.stopTrackMempoolBlock();
|
this.websocketService.stopTrackMempoolBlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearBlock(direction): void {
|
|
||||||
if (this.scene) {
|
|
||||||
this.scene.exit(direction);
|
|
||||||
}
|
|
||||||
this.hoverTx = null;
|
|
||||||
this.selectedTx = null;
|
|
||||||
this.txPreviewEvent.emit(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceBlock(transactionsStripped: TransactionStripped[]): void {
|
replaceBlock(transactionsStripped: TransactionStripped[]): void {
|
||||||
if (!this.scene) {
|
|
||||||
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: 75,
|
|
||||||
blockLimit: this.stateService.blockVSize, vertexArray: this.vertexArray });
|
|
||||||
}
|
|
||||||
const blockMined = (this.stateService.latestBlockHeight > this.lastBlockHeight);
|
const blockMined = (this.stateService.latestBlockHeight > this.lastBlockHeight);
|
||||||
if (this.blockIndex !== this.index) {
|
if (this.blockIndex !== this.index) {
|
||||||
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right';
|
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right';
|
||||||
this.scene.enter(transactionsStripped, direction);
|
this.blockGraph.enter(transactionsStripped, direction);
|
||||||
} else {
|
} else {
|
||||||
this.scene.replace(transactionsStripped, blockMined ? 'right' : 'left');
|
this.blockGraph.replace(transactionsStripped, blockMined ? 'right' : 'left');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastBlockHeight = this.stateService.latestBlockHeight;
|
this.lastBlockHeight = this.stateService.latestBlockHeight;
|
||||||
@ -115,20 +78,13 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateBlock(delta: MempoolBlockDelta): void {
|
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);
|
const blockMined = (this.stateService.latestBlockHeight > this.lastBlockHeight);
|
||||||
|
|
||||||
if (this.blockIndex !== this.index) {
|
if (this.blockIndex !== this.index) {
|
||||||
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right';
|
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right';
|
||||||
this.scene.exit(direction);
|
this.blockGraph.replace(delta.added, 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 {
|
} else {
|
||||||
this.scene.update(delta.added, delta.removed, blockMined ? 'right' : 'left', blockMined);
|
this.blockGraph.update(delta.added, delta.removed, blockMined ? 'right' : 'left', blockMined);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastBlockHeight = this.stateService.latestBlockHeight;
|
this.lastBlockHeight = this.stateService.latestBlockHeight;
|
||||||
@ -136,279 +92,8 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
|||||||
this.isLoading$.next(false);
|
this.isLoading$.next(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
initCanvas(): void {
|
onTxClick(event: TransactionStripped): void {
|
||||||
this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
|
||||||
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
this.router.navigate([url]);
|
||||||
|
|
||||||
const shaderSet = [
|
|
||||||
{
|
|
||||||
type: this.gl.VERTEX_SHADER,
|
|
||||||
src: vertShaderSrc
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: this.gl.FRAGMENT_SHADER,
|
|
||||||
src: fragShaderSrc
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
this.shaderProgram = this.buildShaderProgram(shaderSet);
|
|
||||||
|
|
||||||
this.gl.useProgram(this.shaderProgram);
|
|
||||||
|
|
||||||
// Set up alpha blending
|
|
||||||
this.gl.enable(this.gl.BLEND);
|
|
||||||
this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
|
|
||||||
|
|
||||||
const glBuffer = this.gl.createBuffer();
|
|
||||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, glBuffer);
|
|
||||||
|
|
||||||
/* SET UP SHADER ATTRIBUTES */
|
|
||||||
Object.keys(attribs).forEach((key, i) => {
|
|
||||||
attribs[key].pointer = this.gl.getAttribLocation(this.shaderProgram, key);
|
|
||||||
this.gl.enableVertexAttribArray(attribs[key].pointer);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContextLost(event): void {
|
|
||||||
event.preventDefault();
|
|
||||||
cancelAnimationFrame(this.animationFrameRequest);
|
|
||||||
this.animationFrameRequest = null;
|
|
||||||
this.running = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContextRestored(event): void {
|
|
||||||
this.initCanvas();
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('window:resize', ['$event'])
|
|
||||||
resizeCanvas(): void {
|
|
||||||
this.cssWidth = this.canvas.nativeElement.parentElement.clientWidth;
|
|
||||||
this.cssHeight = this.canvas.nativeElement.parentElement.clientHeight;
|
|
||||||
this.displayWidth = window.devicePixelRatio * this.cssWidth;
|
|
||||||
this.displayHeight = window.devicePixelRatio * this.cssHeight;
|
|
||||||
this.canvas.nativeElement.width = this.displayWidth;
|
|
||||||
this.canvas.nativeElement.height = this.displayHeight;
|
|
||||||
if (this.gl) {
|
|
||||||
this.gl.viewport(0, 0, this.displayWidth, this.displayHeight);
|
|
||||||
}
|
|
||||||
if (this.scene) {
|
|
||||||
this.scene.resize({ width: this.displayWidth, height: this.displayHeight });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileShader(src, type): WebGLShader {
|
|
||||||
const shader = this.gl.createShader(type);
|
|
||||||
|
|
||||||
this.gl.shaderSource(shader, src);
|
|
||||||
this.gl.compileShader(shader);
|
|
||||||
|
|
||||||
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
|
||||||
console.log(`Error compiling ${type === this.gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader:`);
|
|
||||||
console.log(this.gl.getShaderInfoLog(shader));
|
|
||||||
}
|
|
||||||
return shader;
|
|
||||||
}
|
|
||||||
|
|
||||||
buildShaderProgram(shaderInfo): WebGLProgram {
|
|
||||||
const program = this.gl.createProgram();
|
|
||||||
|
|
||||||
shaderInfo.forEach((desc) => {
|
|
||||||
const shader = this.compileShader(desc.src, desc.type);
|
|
||||||
if (shader) {
|
|
||||||
this.gl.attachShader(program, shader);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.gl.linkProgram(program);
|
|
||||||
|
|
||||||
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
|
|
||||||
console.log('Error linking shader program:');
|
|
||||||
console.log(this.gl.getProgramInfoLog(program));
|
|
||||||
}
|
|
||||||
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
start(): void {
|
|
||||||
this.running = true;
|
|
||||||
this.ngZone.runOutsideAngular(() => this.run());
|
|
||||||
}
|
|
||||||
|
|
||||||
run(now?: DOMHighResTimeStamp): void {
|
|
||||||
if (!now) {
|
|
||||||
now = performance.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SET UP SHADER UNIFORMS */
|
|
||||||
// screen dimensions
|
|
||||||
this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight);
|
|
||||||
// frame timestamp
|
|
||||||
this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, 'now'), now);
|
|
||||||
|
|
||||||
/* SET UP SHADER ATTRIBUTES */
|
|
||||||
Object.keys(attribs).forEach((key, i) => {
|
|
||||||
this.gl.vertexAttribPointer(attribs[key].pointer,
|
|
||||||
attribs[key].count, // number of primitives in this attribute
|
|
||||||
this.gl[attribs[key].type], // type of primitive in this attribute (e.g. gl.FLOAT)
|
|
||||||
false, // never normalised
|
|
||||||
stride, // distance between values of the same attribute
|
|
||||||
attribs[key].offset); // offset of the first value
|
|
||||||
});
|
|
||||||
|
|
||||||
const pointArray = this.vertexArray.getVertexData();
|
|
||||||
|
|
||||||
if (pointArray.length) {
|
|
||||||
this.gl.bufferData(this.gl.ARRAY_BUFFER, pointArray, this.gl.DYNAMIC_DRAW);
|
|
||||||
this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* LOOP */
|
|
||||||
if (this.running) {
|
|
||||||
if (this.animationFrameRequest) {
|
|
||||||
cancelAnimationFrame(this.animationFrameRequest);
|
|
||||||
this.animationFrameRequest = null;
|
|
||||||
}
|
|
||||||
this.animationFrameRequest = requestAnimationFrame(() => this.run());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('click', ['$event'])
|
|
||||||
onClick(event) {
|
|
||||||
this.setPreviewTx(event.offsetX, event.offsetY, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('pointermove', ['$event'])
|
|
||||||
onPointerMove(event) {
|
|
||||||
this.setPreviewTx(event.offsetX, event.offsetY, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('pointerleave', ['$event'])
|
|
||||||
onPointerLeave(event) {
|
|
||||||
this.setPreviewTx(-1, -1, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreviewTx(cssX: number, cssY: number, clicked: boolean = false) {
|
|
||||||
const x = cssX * window.devicePixelRatio;
|
|
||||||
const y = cssY * window.devicePixelRatio;
|
|
||||||
if (this.scene && (!this.selectedTx || clicked)) {
|
|
||||||
const selected = this.scene.getTxAt({ x, y });
|
|
||||||
const currentPreview = this.selectedTx || this.hoverTx;
|
|
||||||
|
|
||||||
if (selected !== currentPreview) {
|
|
||||||
if (currentPreview) {
|
|
||||||
currentPreview.setHover(false);
|
|
||||||
}
|
|
||||||
if (selected) {
|
|
||||||
selected.setHover(true);
|
|
||||||
this.txPreviewEvent.emit({
|
|
||||||
txid: selected.txid,
|
|
||||||
fee: selected.fee,
|
|
||||||
vsize: selected.vsize,
|
|
||||||
value: selected.value
|
|
||||||
});
|
|
||||||
if (clicked) {
|
|
||||||
this.selectedTx = selected;
|
|
||||||
} else {
|
|
||||||
this.hoverTx = selected;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (clicked) {
|
|
||||||
this.selectedTx = null;
|
|
||||||
}
|
|
||||||
this.hoverTx = null;
|
|
||||||
this.txPreviewEvent.emit(null);
|
|
||||||
}
|
|
||||||
} else if (clicked) {
|
|
||||||
if (selected === this.selectedTx) {
|
|
||||||
this.hoverTx = this.selectedTx;
|
|
||||||
this.selectedTx = null;
|
|
||||||
} else {
|
|
||||||
this.selectedTx = selected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebGL shader attributes
|
|
||||||
const attribs = {
|
|
||||||
offset: { type: 'FLOAT', count: 2, pointer: null, offset: 0 },
|
|
||||||
posX: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
|
||||||
posY: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
|
||||||
posR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
|
||||||
colR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
|
||||||
colG: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
|
||||||
colB: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
|
||||||
colA: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }
|
|
||||||
};
|
|
||||||
// Calculate the number of bytes per vertex based on specified attributes
|
|
||||||
const stride = Object.values(attribs).reduce((total, attrib) => {
|
|
||||||
return total + (attrib.count * 4);
|
|
||||||
}, 0);
|
|
||||||
// Calculate vertex attribute offsets
|
|
||||||
for (let i = 0, offset = 0; i < Object.keys(attribs).length; i++) {
|
|
||||||
const attrib = Object.values(attribs)[i];
|
|
||||||
attrib.offset = offset;
|
|
||||||
offset += (attrib.count * 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
const vertShaderSrc = `
|
|
||||||
varying lowp vec4 vColor;
|
|
||||||
|
|
||||||
// each attribute contains [x: startValue, y: endValue, z: startTime, w: rate]
|
|
||||||
// shader interpolates between start and end values at the given rate, from the given time
|
|
||||||
|
|
||||||
attribute vec2 offset;
|
|
||||||
attribute vec4 posX;
|
|
||||||
attribute vec4 posY;
|
|
||||||
attribute vec4 posR;
|
|
||||||
attribute vec4 colR;
|
|
||||||
attribute vec4 colG;
|
|
||||||
attribute vec4 colB;
|
|
||||||
attribute vec4 colA;
|
|
||||||
|
|
||||||
uniform vec2 screenSize;
|
|
||||||
uniform float now;
|
|
||||||
|
|
||||||
float smootherstep(float x) {
|
|
||||||
x = clamp(x, 0.0, 1.0);
|
|
||||||
float ix = 1.0 - x;
|
|
||||||
x = x * x;
|
|
||||||
return x / (x + ix * ix);
|
|
||||||
}
|
|
||||||
|
|
||||||
float interpolateAttribute(vec4 attr) {
|
|
||||||
float d = (now - attr.z) * attr.w;
|
|
||||||
float delta = smootherstep(d);
|
|
||||||
return mix(attr.x, attr.y, delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vec4 screenTransform = vec4(2.0 / screenSize.x, 2.0 / screenSize.y, -1.0, -1.0);
|
|
||||||
// vec4 screenTransform = vec4(1.0 / screenSize.x, 1.0 / screenSize.y, -0.5, -0.5);
|
|
||||||
|
|
||||||
float radius = interpolateAttribute(posR);
|
|
||||||
vec2 position = vec2(interpolateAttribute(posX), interpolateAttribute(posY)) + (radius * offset);
|
|
||||||
|
|
||||||
gl_Position = vec4(position * screenTransform.xy + screenTransform.zw, 1.0, 1.0);
|
|
||||||
|
|
||||||
float red = interpolateAttribute(colR);
|
|
||||||
float green = interpolateAttribute(colG);
|
|
||||||
float blue = interpolateAttribute(colB);
|
|
||||||
float alpha = interpolateAttribute(colA);
|
|
||||||
|
|
||||||
vColor = vec4(red, green, blue, alpha);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const fragShaderSrc = `
|
|
||||||
varying lowp vec4 vColor;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
gl_FragColor = vColor;
|
|
||||||
// premultiply alpha
|
|
||||||
gl_FragColor.rgb *= gl_FragColor.a;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
@ -10,62 +10,33 @@
|
|||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<table class="table table-borderless table-striped table-fixed">
|
<table class="table table-borderless table-striped">
|
||||||
<tbody>
|
<tbody>
|
||||||
<ng-container *ngIf="!previewTx">
|
<tr>
|
||||||
<tr>
|
<td i18n="mempool-block.median-fee">Median fee</td>
|
||||||
<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>
|
||||||
<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>
|
<tr>
|
||||||
<tr>
|
<td i18n="mempool-block.fee-span">Fee span</td>
|
||||||
<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>
|
||||||
<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>
|
<tr>
|
||||||
<tr>
|
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
|
||||||
<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>
|
||||||
<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>
|
<tr>
|
||||||
<tr>
|
<td i18n="mempool-block.transactions">Transactions</td>
|
||||||
<td i18n="mempool-block.transactions">Transactions</td>
|
<td>{{ mempoolBlock.nTx }}</td>
|
||||||
<td>{{ mempoolBlock.nTx }}</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td i18n="mempool-block.size">Size</td>
|
||||||
<td i18n="mempool-block.size">Size</td>
|
<td>
|
||||||
<td>
|
<div class="progress">
|
||||||
<div class="progress">
|
<div class="progress-bar progress-mempool {{ (network$ | async) }}" role="progressbar" [ngStyle]="{'width': (mempoolBlock.blockVSize / stateService.blockVSize) * 100 + '%' }"></div>
|
||||||
<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 class="progress-text" [innerHTML]="mempoolBlock.blockSize | bytes: 2"></div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<app-fee-distribution-graph *ngIf="webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph>
|
<app-fee-distribution-graph *ngIf="webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph>
|
||||||
|
@ -81,12 +81,12 @@ export class MempoolBlockComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTxPreview(event: TransactionStripped | void): void {
|
setTxPreview(event: TransactionStripped | void): void {
|
||||||
this.previewTx = event
|
this.previewTx = event;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectWebGL () {
|
function detectWebGL() {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement('canvas');
|
||||||
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||||
return (gl && gl instanceof WebGLRenderingContext)
|
return (gl && gl instanceof WebGLRenderingContext);
|
||||||
}
|
}
|
||||||
|
@ -128,6 +128,13 @@ export interface BlockExtended extends Block {
|
|||||||
extras?: BlockExtension;
|
extras?: BlockExtension;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransactionStripped {
|
||||||
|
txid: string;
|
||||||
|
fee: number;
|
||||||
|
vsize: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RewardStats {
|
export interface RewardStats {
|
||||||
startBlock: number;
|
startBlock: number;
|
||||||
endBlock: number;
|
endBlock: number;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolsStats, PoolStat, BlockExtended, RewardStats } from '../interfaces/node-api.interface';
|
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
|
||||||
|
PoolStat, BlockExtended, TransactionStripped, RewardStats } from '../interfaces/node-api.interface';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { StateService } from './state.service';
|
import { StateService } from './state.service';
|
||||||
import { WebsocketResponse } from '../interfaces/websocket.interface';
|
import { WebsocketResponse } from '../interfaces/websocket.interface';
|
||||||
@ -158,6 +159,10 @@ export class ApiService {
|
|||||||
return this.httpClient.get<BlockExtended>(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash);
|
return this.httpClient.get<BlockExtended>(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStrippedBlockTransactions$(hash: string): Observable<TransactionStripped[]> {
|
||||||
|
return this.httpClient.get<TransactionStripped[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash + '/summary');
|
||||||
|
}
|
||||||
|
|
||||||
getHistoricalHashrate$(interval: string | undefined): Observable<any> {
|
getHistoricalHashrate$(interval: string | undefined): Observable<any> {
|
||||||
return this.httpClient.get<any[]>(
|
return this.httpClient.get<any[]>(
|
||||||
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/hashrate` +
|
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/hashrate` +
|
||||||
|
@ -45,6 +45,8 @@ import { StartComponent } from '../components/start/start.component';
|
|||||||
import { TransactionComponent } from '../components/transaction/transaction.component';
|
import { TransactionComponent } from '../components/transaction/transaction.component';
|
||||||
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
|
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
|
||||||
import { BlockComponent } from '../components/block/block.component';
|
import { BlockComponent } from '../components/block/block.component';
|
||||||
|
import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component';
|
||||||
|
import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component';
|
||||||
import { AddressComponent } from '../components/address/address.component';
|
import { AddressComponent } from '../components/address/address.component';
|
||||||
import { SearchFormComponent } from '../components/search-form/search-form.component';
|
import { SearchFormComponent } from '../components/search-form/search-form.component';
|
||||||
import { AddressLabelsComponent } from '../components/address-labels/address-labels.component';
|
import { AddressLabelsComponent } from '../components/address-labels/address-labels.component';
|
||||||
@ -110,6 +112,8 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
|
|||||||
StartComponent,
|
StartComponent,
|
||||||
TransactionComponent,
|
TransactionComponent,
|
||||||
BlockComponent,
|
BlockComponent,
|
||||||
|
BlockOverviewGraphComponent,
|
||||||
|
BlockOverviewTooltipComponent,
|
||||||
TransactionsListComponent,
|
TransactionsListComponent,
|
||||||
AddressComponent,
|
AddressComponent,
|
||||||
SearchFormComponent,
|
SearchFormComponent,
|
||||||
@ -203,6 +207,8 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
|
|||||||
StartComponent,
|
StartComponent,
|
||||||
TransactionComponent,
|
TransactionComponent,
|
||||||
BlockComponent,
|
BlockComponent,
|
||||||
|
BlockOverviewGraphComponent,
|
||||||
|
BlockOverviewTooltipComponent,
|
||||||
TransactionsListComponent,
|
TransactionsListComponent,
|
||||||
AddressComponent,
|
AddressComponent,
|
||||||
SearchFormComponent,
|
SearchFormComponent,
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
"CLEAR_PROTECTION_MINUTES": 5,
|
"CLEAR_PROTECTION_MINUTES": 5,
|
||||||
"POLL_RATE_MS": 1000,
|
"POLL_RATE_MS": 1000,
|
||||||
"INDEXING_BLOCKS_AMOUNT": -1,
|
"INDEXING_BLOCKS_AMOUNT": -1,
|
||||||
|
"BLOCKS_SUMMARIES_INDEXING": true,
|
||||||
"USE_SECOND_NODE_FOR_MINFEE": true
|
"USE_SECOND_NODE_FOR_MINFEE": true
|
||||||
},
|
},
|
||||||
"SYSLOG" : {
|
"SYSLOG" : {
|
||||||
|
@ -4,14 +4,42 @@ location /api/v1/statistics {
|
|||||||
location /api/v1/mining {
|
location /api/v1/mining {
|
||||||
try_files /dev/null @mempool-api-v1-warmcache;
|
try_files /dev/null @mempool-api-v1-warmcache;
|
||||||
}
|
}
|
||||||
|
location /api/v1/block {
|
||||||
|
try_files /dev/null @mempool-api-v1-forevercache;
|
||||||
|
}
|
||||||
location /api/v1 {
|
location /api/v1 {
|
||||||
try_files /dev/null @mempool-api-v1-coldcache;
|
try_files /dev/null @mempool-api-v1-coldcache;
|
||||||
}
|
}
|
||||||
|
location /api/block {
|
||||||
|
rewrite ^/api/(.*) /$1 break;
|
||||||
|
try_files /dev/null @electrs-api-forevercache;
|
||||||
|
}
|
||||||
location /api/ {
|
location /api/ {
|
||||||
rewrite ^/api/(.*) /$1 break;
|
rewrite ^/api/(.*) /$1 break;
|
||||||
try_files /dev/null @electrs-api-nocache;
|
try_files /dev/null @electrs-api-nocache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location @mempool-api-v1-forevercache {
|
||||||
|
proxy_pass $mempoolBackend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_cache_background_update on;
|
||||||
|
proxy_cache_use_stale updating;
|
||||||
|
proxy_cache api;
|
||||||
|
proxy_cache_valid 200 30d;
|
||||||
|
proxy_redirect off;
|
||||||
|
|
||||||
|
expires 30d;
|
||||||
|
}
|
||||||
|
|
||||||
location @mempool-api-v1-warmcache {
|
location @mempool-api-v1-warmcache {
|
||||||
proxy_pass $mempoolBackend;
|
proxy_pass $mempoolBackend;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@ -46,6 +74,7 @@ location @mempool-api-v1-coldcache {
|
|||||||
proxy_cache api;
|
proxy_cache api;
|
||||||
proxy_cache_valid 200 10s;
|
proxy_cache_valid 200 10s;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
expires 10s;
|
expires 10s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,4 +110,27 @@ location @electrs-api-nocache {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @electrs-api-forevercache {
|
||||||
|
proxy_pass $electrsBackend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_cache_background_update on;
|
||||||
|
proxy_cache_use_stale updating;
|
||||||
|
proxy_cache api;
|
||||||
|
proxy_cache_valid 200 30d;
|
||||||
|
proxy_redirect off;
|
||||||
|
|
||||||
|
expires 30d;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user