Initial code commit.
42
backend/.gitignore
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage/*
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# e2e
|
||||
/e2e/*.js
|
||||
/e2e/*.map
|
||||
|
||||
#System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
cache.json
|
22
backend/mempool-config.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"ENV": "dev",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": 8889,
|
||||
"DB_USER": "",
|
||||
"DB_PASSWORD": "",
|
||||
"DB_DATABASE": "",
|
||||
"HTTP_PORT": 3000,
|
||||
"API_ENDPOINT": "/api/v1/",
|
||||
"CHAT_SSL_ENABLED": false,
|
||||
"CHAT_SSL_PRIVKEY": "",
|
||||
"CHAT_SSL_CERT": "",
|
||||
"CHAT_SSL_CHAIN": "",
|
||||
"MEMPOOL_REFRESH_RATE_MS": 500,
|
||||
"INITIAL_BLOCK_AMOUNT": 8,
|
||||
"KEEP_BLOCK_AMOUNT": 24,
|
||||
"BITCOIN_NODE_HOST": "localhost",
|
||||
"BITCOIN_NODE_PORT": 18332,
|
||||
"BITCOIN_NODE_USER": "",
|
||||
"BITCOIN_NODE_PASS": "",
|
||||
"TX_PER_SECOND_SPAN_SECONDS": 150
|
||||
}
|
27
backend/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "mempool-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Bitcoin mempool visualizer",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bitcoin": "^3.0.1",
|
||||
"compression": "^1.7.3",
|
||||
"express": "^4.16.3",
|
||||
"mysql2": "^1.6.1",
|
||||
"request": "^2.88.0",
|
||||
"ws": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.16.0",
|
||||
"@types/mysql2": "github:types/mysql2",
|
||||
"@types/request": "^2.48.2",
|
||||
"@types/ws": "^6.0.1",
|
||||
"tslint": "^5.11.0",
|
||||
"typescript": "^3.1.1"
|
||||
}
|
||||
}
|
84
backend/src/api/bitcoin-api-wrapper.ts
Normal file
@ -0,0 +1,84 @@
|
||||
const config = require('../../mempool-config.json');
|
||||
import * as bitcoin from 'bitcoin';
|
||||
import { ITransaction, IMempoolInfo, IBlock } from '../interfaces';
|
||||
|
||||
class BitcoinApi {
|
||||
client: any;
|
||||
|
||||
constructor() {
|
||||
this.client = new bitcoin.Client({
|
||||
host: config.BITCOIN_NODE_HOST,
|
||||
port: config.BITCOIN_NODE_PORT,
|
||||
user: config.BITCOIN_NODE_USER,
|
||||
pass: config.BITCOIN_NODE_PASS,
|
||||
});
|
||||
}
|
||||
|
||||
getMempoolInfo(): Promise<IMempoolInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.getMempoolInfo((err: Error, mempoolInfo: any) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(mempoolInfo);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getRawMempool(): Promise<ITransaction['txid'][]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.getRawMemPool((err: Error, transactions: ITransaction['txid'][]) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(transactions);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getRawTransaction(txId: string): Promise<ITransaction> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.getRawTransaction(txId, true, (err: Error, txData: ITransaction) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(txData);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getBlockCount(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.getBlockCount((err: Error, response: number) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getBlock(hash: string, verbosity: 1 | 2 = 1): Promise<IBlock> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.getBlock(hash, verbosity, (err: Error, block: IBlock) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(block);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getBlockHash(height: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.getBlockHash(height, (err: Error, response: string) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new BitcoinApi();
|
197
backend/src/api/blocks.ts
Normal file
@ -0,0 +1,197 @@
|
||||
const config = require('../../mempool-config.json');
|
||||
import bitcoinApi from './bitcoin-api-wrapper';
|
||||
import { DB } from '../database';
|
||||
import { IBlock, ITransaction } from '../interfaces';
|
||||
import memPool from './mempool';
|
||||
|
||||
class Blocks {
|
||||
private blocks: IBlock[] = [];
|
||||
private newBlockCallback: Function | undefined;
|
||||
|
||||
public setNewBlockCallback(fn: Function) {
|
||||
this.newBlockCallback = fn;
|
||||
}
|
||||
|
||||
public getBlocks(): IBlock[] {
|
||||
return this.blocks;
|
||||
}
|
||||
|
||||
public formatBlock(block: IBlock) {
|
||||
return {
|
||||
hash: block.hash,
|
||||
height: block.height,
|
||||
nTx: block.nTx - 1,
|
||||
size: block.size,
|
||||
time: block.time,
|
||||
weight: block.weight,
|
||||
fees: block.fees,
|
||||
minFee: block.minFee,
|
||||
maxFee: block.maxFee,
|
||||
medianFee: block.medianFee,
|
||||
};
|
||||
}
|
||||
|
||||
public async updateBlocks() {
|
||||
try {
|
||||
const blockCount = await bitcoinApi.getBlockCount();
|
||||
|
||||
let currentBlockHeight = 0;
|
||||
if (this.blocks.length === 0) {
|
||||
currentBlockHeight = blockCount - config.INITIAL_BLOCK_AMOUNT;
|
||||
} else {
|
||||
currentBlockHeight = this.blocks[this.blocks.length - 1].height;
|
||||
}
|
||||
|
||||
while (currentBlockHeight < blockCount) {
|
||||
currentBlockHeight++;
|
||||
|
||||
let block: IBlock | undefined;
|
||||
|
||||
const storedBlock = await this.$getBlockFromDatabase(currentBlockHeight);
|
||||
if (storedBlock) {
|
||||
block = storedBlock;
|
||||
} else {
|
||||
const blockHash = await bitcoinApi.getBlockHash(currentBlockHeight);
|
||||
block = await bitcoinApi.getBlock(blockHash, 1);
|
||||
|
||||
const coinbase = await memPool.getRawTransaction(block.tx[0], true);
|
||||
if (coinbase && coinbase.totalOut) {
|
||||
block.fees = coinbase.totalOut;
|
||||
}
|
||||
|
||||
const mempool = memPool.getMempool();
|
||||
let found = 0;
|
||||
let notFound = 0;
|
||||
|
||||
let transactions: ITransaction[] = [];
|
||||
|
||||
for (let i = 1; i < block.tx.length; i++) {
|
||||
if (mempool[block.tx[i]]) {
|
||||
transactions.push(mempool[block.tx[i]]);
|
||||
found++;
|
||||
} else {
|
||||
const tx = await memPool.getRawTransaction(block.tx[i]);
|
||||
if (tx) {
|
||||
transactions.push(tx);
|
||||
}
|
||||
notFound++;
|
||||
}
|
||||
}
|
||||
|
||||
transactions.sort((a, b) => b.feePerVsize - a.feePerVsize);
|
||||
transactions = transactions.filter((tx: ITransaction) => tx.feePerVsize);
|
||||
|
||||
block.minFee = transactions[transactions.length - 1] ? transactions[transactions.length - 1].feePerVsize : 0;
|
||||
block.maxFee = transactions[0] ? transactions[0].feePerVsize : 0;
|
||||
block.medianFee = this.median(transactions.map((tx) => tx.feePerVsize));
|
||||
|
||||
if (this.newBlockCallback) {
|
||||
this.newBlockCallback(block);
|
||||
}
|
||||
|
||||
await this.$saveBlockToDatabase(block);
|
||||
await this.$saveTransactionsToDatabase(block.height, transactions);
|
||||
console.log(`New block found (#${currentBlockHeight})! ${found} of ${block.tx.length} found in mempool. ${notFound} not found.`);
|
||||
}
|
||||
|
||||
this.blocks.push(block);
|
||||
if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) {
|
||||
this.blocks.shift();
|
||||
}
|
||||
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Error getBlockCount', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getBlockFromDatabase(height: number): Promise<IBlock | undefined> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `
|
||||
SELECT * FROM blocks WHERE height = ?
|
||||
`;
|
||||
|
||||
const [rows] = await connection.query<any>(query, [height]);
|
||||
connection.release();
|
||||
|
||||
if (rows[0]) {
|
||||
return rows[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('$get() block error', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $saveBlockToDatabase(block: IBlock) {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `
|
||||
INSERT IGNORE INTO blocks
|
||||
(height, hash, size, weight, minFee, maxFee, time, fees, nTx, medianFee)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const params: (any)[] = [
|
||||
block.height,
|
||||
block.hash,
|
||||
block.size,
|
||||
block.weight,
|
||||
block.minFee,
|
||||
block.maxFee,
|
||||
block.time,
|
||||
block.fees,
|
||||
block.nTx - 1,
|
||||
block.medianFee,
|
||||
];
|
||||
|
||||
await connection.query(query, params);
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
console.log('$create() block error', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $saveTransactionsToDatabase(blockheight: number, transactions: ITransaction[]) {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
|
||||
for (let i = 0; i < transactions.length; i++) {
|
||||
const query = `
|
||||
INSERT IGNORE INTO transactions
|
||||
(blockheight, txid, fee, feePerVsize)
|
||||
VALUES(?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const params: (any)[] = [
|
||||
blockheight,
|
||||
transactions[i].txid,
|
||||
transactions[i].fee,
|
||||
transactions[i].feePerVsize,
|
||||
];
|
||||
|
||||
await connection.query(query, params);
|
||||
}
|
||||
|
||||
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
console.log('$create() transaction error', e);
|
||||
}
|
||||
}
|
||||
|
||||
private median(numbers: number[]) {
|
||||
if (!numbers.length) { return 0; }
|
||||
let medianNr = 0;
|
||||
const numsLen = numbers.length;
|
||||
numbers.sort();
|
||||
if (numsLen % 2 === 0) {
|
||||
medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2;
|
||||
} else {
|
||||
medianNr = numbers[(numsLen - 1) / 2];
|
||||
}
|
||||
return medianNr;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Blocks();
|
16
backend/src/api/disk-cache.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
class DiskCache {
|
||||
static FILE_NAME = './cache.json';
|
||||
constructor() { }
|
||||
|
||||
saveData(dataBlob: string) {
|
||||
fs.writeFileSync(DiskCache.FILE_NAME, dataBlob, 'utf8');
|
||||
}
|
||||
|
||||
loadData(): string {
|
||||
return fs.readFileSync(DiskCache.FILE_NAME, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
export default new DiskCache();
|
47
backend/src/api/fee-api.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import projectedBlocks from './projected-blocks';
|
||||
import { DB } from '../database';
|
||||
|
||||
class FeeApi {
|
||||
constructor() { }
|
||||
|
||||
public getRecommendedFee() {
|
||||
const pBlocks = projectedBlocks.getProjectedBlocks();
|
||||
if (!pBlocks.length) {
|
||||
return {
|
||||
'fastestFee': 0,
|
||||
'halfHourFee': 0,
|
||||
'hourFee': 0,
|
||||
};
|
||||
}
|
||||
let firstMedianFee = Math.ceil(pBlocks[0].medianFee);
|
||||
|
||||
if (pBlocks.length === 1 && pBlocks[0].blockWeight <= 2000000) {
|
||||
firstMedianFee = 1;
|
||||
}
|
||||
|
||||
const secondMedianFee = pBlocks[1] ? Math.ceil(pBlocks[1].medianFee) : firstMedianFee;
|
||||
const thirdMedianFee = pBlocks[2] ? Math.ceil(pBlocks[2].medianFee) : secondMedianFee;
|
||||
|
||||
return {
|
||||
'fastestFee': firstMedianFee,
|
||||
'halfHourFee': secondMedianFee,
|
||||
'hourFee': thirdMedianFee,
|
||||
};
|
||||
}
|
||||
|
||||
public async $getTransactionsForBlock(blockHeight: number): Promise<any[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT feePerVsize AS fpv FROM transactions WHERE blockheight = ? ORDER BY feePerVsize ASC`;
|
||||
const [rows] = await connection.query<any>(query, [blockHeight]);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.log('$getTransactionsForBlock() error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new FeeApi();
|
31
backend/src/api/fiat-conversion.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import * as request from 'request';
|
||||
|
||||
class FiatConversion {
|
||||
private tickers = {
|
||||
'BTCUSD': {
|
||||
'USD': 4110.78
|
||||
},
|
||||
};
|
||||
|
||||
constructor() { }
|
||||
|
||||
public startService() {
|
||||
setInterval(this.updateCurrency.bind(this), 1000 * 60 * 60);
|
||||
this.updateCurrency();
|
||||
}
|
||||
|
||||
public getTickers() {
|
||||
return this.tickers;
|
||||
}
|
||||
|
||||
private updateCurrency() {
|
||||
request('https://api.opennode.co/v1/rates', { json: true }, (err, res, body) => {
|
||||
if (err) { return console.log(err); }
|
||||
if (body && body.data) {
|
||||
this.tickers = body.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new FiatConversion();
|
156
backend/src/api/mempool.ts
Normal file
@ -0,0 +1,156 @@
|
||||
const config = require('../../mempool-config.json');
|
||||
import bitcoinApi from './bitcoin-api-wrapper';
|
||||
import { ITransaction, IMempoolInfo, IMempool } from '../interfaces';
|
||||
|
||||
class Mempool {
|
||||
private mempool: IMempool = {};
|
||||
private mempoolInfo: IMempoolInfo | undefined;
|
||||
private mempoolChangedCallback: Function | undefined;
|
||||
|
||||
private txPerSecondArray: number[] = [];
|
||||
private txPerSecond: number = 0;
|
||||
|
||||
private vBytesPerSecondArray: any[] = [];
|
||||
private vBytesPerSecond: number = 0;
|
||||
|
||||
constructor() {
|
||||
setInterval(this.updateTxPerSecond.bind(this), 1000);
|
||||
}
|
||||
|
||||
public setMempoolChangedCallback(fn: Function) {
|
||||
this.mempoolChangedCallback = fn;
|
||||
}
|
||||
|
||||
public getMempool(): { [txid: string]: ITransaction } {
|
||||
return this.mempool;
|
||||
}
|
||||
|
||||
public setMempool(mempoolData: any) {
|
||||
this.mempool = mempoolData;
|
||||
}
|
||||
|
||||
public getMempoolInfo(): IMempoolInfo | undefined {
|
||||
return this.mempoolInfo;
|
||||
}
|
||||
|
||||
public getTxPerSecond(): number {
|
||||
return this.txPerSecond;
|
||||
}
|
||||
|
||||
public getVBytesPerSecond(): number {
|
||||
return this.vBytesPerSecond;
|
||||
}
|
||||
|
||||
public async getMemPoolInfo() {
|
||||
try {
|
||||
this.mempoolInfo = await bitcoinApi.getMempoolInfo();
|
||||
} catch (err) {
|
||||
console.log('Error getMempoolInfo', err);
|
||||
}
|
||||
}
|
||||
|
||||
public async getRawTransaction(txId: string, isCoinbase = false): Promise<ITransaction | false> {
|
||||
try {
|
||||
const transaction = await bitcoinApi.getRawTransaction(txId);
|
||||
let totalIn = 0;
|
||||
if (!isCoinbase) {
|
||||
for (let i = 0; i < transaction.vin.length; i++) {
|
||||
try {
|
||||
const result = await bitcoinApi.getRawTransaction(transaction.vin[i].txid);
|
||||
transaction.vin[i]['value'] = result.vout[transaction.vin[i].vout].value;
|
||||
totalIn += result.vout[transaction.vin[i].vout].value;
|
||||
} catch (err) {
|
||||
console.log('Locating historical tx error');
|
||||
}
|
||||
}
|
||||
}
|
||||
let totalOut = 0;
|
||||
transaction.vout.forEach((output) => totalOut += output.value);
|
||||
|
||||
if (totalIn > totalOut) {
|
||||
transaction.fee = parseFloat((totalIn - totalOut).toFixed(8));
|
||||
transaction.feePerWeightUnit = (transaction.fee * 100000000) / (transaction.vsize * 4) || 0;
|
||||
transaction.feePerVsize = (transaction.fee * 100000000) / (transaction.vsize) || 0;
|
||||
} else if (!isCoinbase) {
|
||||
transaction.fee = 0;
|
||||
transaction.feePerVsize = 0;
|
||||
transaction.feePerWeightUnit = 0;
|
||||
console.log('Minus fee error!');
|
||||
}
|
||||
transaction.totalOut = totalOut;
|
||||
return transaction;
|
||||
} catch (e) {
|
||||
console.log(txId + ' not found');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async updateMempool() {
|
||||
console.log('Updating mempool');
|
||||
const start = new Date().getTime();
|
||||
let hasChange: boolean = false;
|
||||
let txCount = 0;
|
||||
try {
|
||||
const transactions = await bitcoinApi.getRawMempool();
|
||||
const diff = transactions.length - Object.keys(this.mempool).length;
|
||||
for (const tx of transactions) {
|
||||
if (!this.mempool[tx]) {
|
||||
const transaction = await this.getRawTransaction(tx);
|
||||
if (transaction) {
|
||||
this.mempool[tx] = transaction;
|
||||
txCount++;
|
||||
this.txPerSecondArray.push(new Date().getTime());
|
||||
this.vBytesPerSecondArray.push({
|
||||
unixTime: new Date().getTime(),
|
||||
vSize: transaction.vsize,
|
||||
});
|
||||
hasChange = true;
|
||||
if (diff > 0) {
|
||||
console.log('Calculated fee for transaction ' + txCount + ' / ' + diff);
|
||||
} else {
|
||||
console.log('Calculated fee for transaction ' + txCount);
|
||||
}
|
||||
} else {
|
||||
console.log('Error finding transaction in mempool.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newMempool: IMempool = {};
|
||||
transactions.forEach((tx) => {
|
||||
if (this.mempool[tx]) {
|
||||
newMempool[tx] = this.mempool[tx];
|
||||
} else {
|
||||
hasChange = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.mempool = newMempool;
|
||||
|
||||
if (hasChange && this.mempoolChangedCallback) {
|
||||
this.mempoolChangedCallback(this.mempool);
|
||||
}
|
||||
|
||||
const end = new Date().getTime();
|
||||
const time = end - start;
|
||||
console.log('Mempool updated in ' + time / 1000 + ' seconds');
|
||||
} catch (err) {
|
||||
console.log('getRawMempool error.', err);
|
||||
}
|
||||
}
|
||||
|
||||
private updateTxPerSecond() {
|
||||
const nowMinusTimeSpan = new Date().getTime() - (1000 * config.TX_PER_SECOND_SPAN_SECONDS);
|
||||
this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan);
|
||||
this.txPerSecond = this.txPerSecondArray.length / config.TX_PER_SECOND_SPAN_SECONDS || 0;
|
||||
|
||||
this.vBytesPerSecondArray = this.vBytesPerSecondArray.filter((data) => data.unixTime > nowMinusTimeSpan);
|
||||
if (this.vBytesPerSecondArray.length) {
|
||||
this.vBytesPerSecond = Math.round(
|
||||
this.vBytesPerSecondArray.map((data) => data.vSize).reduce((a, b) => a + b) / config.TX_PER_SECOND_SPAN_SECONDS
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Mempool();
|
104
backend/src/api/projected-blocks.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { ITransaction, IProjectedBlock, IMempool, IProjectedBlockInternal } from '../interfaces';
|
||||
|
||||
class ProjectedBlocks {
|
||||
private projectedBlocks: IProjectedBlockInternal[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
public getProjectedBlocks(txId?: string): IProjectedBlock[] {
|
||||
return this.projectedBlocks.map((projectedBlock) => {
|
||||
return {
|
||||
blockSize: projectedBlock.blockSize,
|
||||
blockWeight: projectedBlock.blockWeight,
|
||||
nTx: projectedBlock.nTx,
|
||||
minFee: projectedBlock.minFee,
|
||||
maxFee: projectedBlock.maxFee,
|
||||
minWeightFee: projectedBlock.minWeightFee,
|
||||
maxWeightFee: projectedBlock.maxWeightFee,
|
||||
medianFee: projectedBlock.medianFee,
|
||||
fees: projectedBlock.fees,
|
||||
hasMytx: txId ? projectedBlock.txIds.some((tx) => tx === txId) : false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public getProjectedBlockFeesForBlock(index: number) {
|
||||
const projectedBlock = this.projectedBlocks[index];
|
||||
|
||||
if (!projectedBlock) {
|
||||
throw new Error('No projected block for that index');
|
||||
}
|
||||
|
||||
return projectedBlock.txFeePerVsizes.map((fpv) => {
|
||||
return {'fpv': fpv};
|
||||
});
|
||||
}
|
||||
|
||||
public updateProjectedBlocks(memPool: IMempool): void {
|
||||
const latestMempool = memPool;
|
||||
const memPoolArray: ITransaction[] = [];
|
||||
for (const i in latestMempool) {
|
||||
if (latestMempool.hasOwnProperty(i)) {
|
||||
memPoolArray.push(latestMempool[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!memPoolArray.length) {
|
||||
this.projectedBlocks = [];
|
||||
}
|
||||
|
||||
memPoolArray.sort((a, b) => b.feePerWeightUnit - a.feePerWeightUnit);
|
||||
const memPoolArrayFiltered = memPoolArray.filter((tx) => tx.feePerWeightUnit);
|
||||
const projectedBlocks: any = [];
|
||||
|
||||
let blockWeight = 0;
|
||||
let blockSize = 0;
|
||||
let transactions: ITransaction[] = [];
|
||||
memPoolArrayFiltered.forEach((tx) => {
|
||||
if (blockWeight + tx.vsize * 4 < 4000000 || projectedBlocks.length === 3) {
|
||||
blockWeight += tx.vsize * 4;
|
||||
blockSize += tx.size;
|
||||
transactions.push(tx);
|
||||
} else {
|
||||
projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight));
|
||||
blockWeight = 0;
|
||||
blockSize = 0;
|
||||
transactions = [];
|
||||
}
|
||||
});
|
||||
if (transactions.length) {
|
||||
projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight));
|
||||
}
|
||||
this.projectedBlocks = projectedBlocks;
|
||||
}
|
||||
|
||||
private dataToProjectedBlock(transactions: ITransaction[], blockSize: number, blockWeight: number): IProjectedBlockInternal {
|
||||
return {
|
||||
blockSize: blockSize,
|
||||
blockWeight: blockWeight,
|
||||
nTx: transactions.length - 1,
|
||||
minFee: transactions[transactions.length - 1].feePerVsize,
|
||||
maxFee: transactions[0].feePerVsize,
|
||||
minWeightFee: transactions[transactions.length - 1].feePerWeightUnit,
|
||||
maxWeightFee: transactions[0].feePerWeightUnit,
|
||||
medianFee: this.median(transactions.map((tx) => tx.feePerVsize)),
|
||||
txIds: transactions.map((tx) => tx.txid),
|
||||
txFeePerVsizes: transactions.map((tx) => tx.feePerVsize).reverse(),
|
||||
fees: transactions.map((tx) => tx.fee).reduce((acc, currValue) => acc + currValue),
|
||||
};
|
||||
}
|
||||
|
||||
private median(numbers: number[]) {
|
||||
let medianNr = 0;
|
||||
const numsLen = numbers.length;
|
||||
numbers.sort();
|
||||
if (numsLen % 2 === 0) {
|
||||
medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2;
|
||||
} else {
|
||||
medianNr = numbers[(numsLen - 1) / 2];
|
||||
}
|
||||
return medianNr;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ProjectedBlocks();
|
379
backend/src/api/statistics.ts
Normal file
@ -0,0 +1,379 @@
|
||||
import memPool from './mempool';
|
||||
import { DB } from '../database';
|
||||
|
||||
import { ITransaction, IMempoolStats } from '../interfaces';
|
||||
|
||||
class Statistics {
|
||||
protected intervalTimer: NodeJS.Timer | undefined;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
public startStatistics(): void {
|
||||
const now = new Date();
|
||||
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
|
||||
Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0);
|
||||
const difference = nextInterval.getTime() - now.getTime();
|
||||
|
||||
setTimeout(() => {
|
||||
this.runStatistics();
|
||||
this.intervalTimer = setInterval(() => { this.runStatistics(); }, 1 * 60 * 1000);
|
||||
}, difference);
|
||||
}
|
||||
|
||||
private runStatistics(): void {
|
||||
const currentMempool = memPool.getMempool();
|
||||
const txPerSecond = memPool.getTxPerSecond();
|
||||
const vBytesPerSecond = memPool.getVBytesPerSecond();
|
||||
|
||||
if (txPerSecond === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Running statistics');
|
||||
|
||||
let memPoolArray: ITransaction[] = [];
|
||||
for (const i in currentMempool) {
|
||||
if (currentMempool.hasOwnProperty(i)) {
|
||||
memPoolArray.push(currentMempool[i]);
|
||||
}
|
||||
}
|
||||
// Remove 0 and undefined
|
||||
memPoolArray = memPoolArray.filter((tx) => tx.feePerWeightUnit);
|
||||
|
||||
if (!memPoolArray.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
memPoolArray.sort((a, b) => a.feePerWeightUnit - b.feePerWeightUnit);
|
||||
const totalWeight = memPoolArray.map((tx) => tx.vsize).reduce((acc, curr) => acc + curr) * 4;
|
||||
const totalFee = memPoolArray.map((tx) => tx.fee).reduce((acc, curr) => acc + curr);
|
||||
|
||||
const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
|
||||
250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000];
|
||||
|
||||
const weightUnitFees: { [feePerWU: number]: number } = {};
|
||||
const weightVsizeFees: { [feePerWU: number]: number } = {};
|
||||
|
||||
memPoolArray.forEach((transaction) => {
|
||||
for (let i = 0; i < logFees.length; i++) {
|
||||
if ((logFees[i] === 2000 && transaction.feePerWeightUnit >= 2000) || transaction.feePerWeightUnit <= logFees[i]) {
|
||||
if (weightUnitFees[logFees[i]]) {
|
||||
weightUnitFees[logFees[i]] += transaction.vsize * 4;
|
||||
} else {
|
||||
weightUnitFees[logFees[i]] = transaction.vsize * 4;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
memPoolArray.forEach((transaction) => {
|
||||
for (let i = 0; i < logFees.length; i++) {
|
||||
if ((logFees[i] === 2000 && transaction.feePerVsize >= 2000) || transaction.feePerVsize <= logFees[i]) {
|
||||
if (weightVsizeFees[logFees[i]]) {
|
||||
weightVsizeFees[logFees[i]] += transaction.vsize;
|
||||
} else {
|
||||
weightVsizeFees[logFees[i]] = transaction.vsize;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.$create({
|
||||
added: 'NOW()',
|
||||
unconfirmed_transactions: memPoolArray.length,
|
||||
tx_per_second: txPerSecond,
|
||||
vbytes_per_second: Math.round(vBytesPerSecond),
|
||||
mempool_byte_weight: totalWeight,
|
||||
total_fee: totalFee,
|
||||
fee_data: JSON.stringify({
|
||||
'wu': weightUnitFees,
|
||||
'vsize': weightVsizeFees
|
||||
}),
|
||||
vsize_1: weightVsizeFees['1'] || 0,
|
||||
vsize_2: weightVsizeFees['2'] || 0,
|
||||
vsize_3: weightVsizeFees['3'] || 0,
|
||||
vsize_4: weightVsizeFees['4'] || 0,
|
||||
vsize_5: weightVsizeFees['5'] || 0,
|
||||
vsize_6: weightVsizeFees['6'] || 0,
|
||||
vsize_8: weightVsizeFees['8'] || 0,
|
||||
vsize_10: weightVsizeFees['10'] || 0,
|
||||
vsize_12: weightVsizeFees['12'] || 0,
|
||||
vsize_15: weightVsizeFees['15'] || 0,
|
||||
vsize_20: weightVsizeFees['20'] || 0,
|
||||
vsize_30: weightVsizeFees['30'] || 0,
|
||||
vsize_40: weightVsizeFees['40'] || 0,
|
||||
vsize_50: weightVsizeFees['50'] || 0,
|
||||
vsize_60: weightVsizeFees['60'] || 0,
|
||||
vsize_70: weightVsizeFees['70'] || 0,
|
||||
vsize_80: weightVsizeFees['80'] || 0,
|
||||
vsize_90: weightVsizeFees['90'] || 0,
|
||||
vsize_100: weightVsizeFees['100'] || 0,
|
||||
vsize_125: weightVsizeFees['125'] || 0,
|
||||
vsize_150: weightVsizeFees['150'] || 0,
|
||||
vsize_175: weightVsizeFees['175'] || 0,
|
||||
vsize_200: weightVsizeFees['200'] || 0,
|
||||
vsize_250: weightVsizeFees['250'] || 0,
|
||||
vsize_300: weightVsizeFees['300'] || 0,
|
||||
vsize_350: weightVsizeFees['350'] || 0,
|
||||
vsize_400: weightVsizeFees['400'] || 0,
|
||||
vsize_500: weightVsizeFees['500'] || 0,
|
||||
vsize_600: weightVsizeFees['600'] || 0,
|
||||
vsize_700: weightVsizeFees['700'] || 0,
|
||||
vsize_800: weightVsizeFees['800'] || 0,
|
||||
vsize_900: weightVsizeFees['900'] || 0,
|
||||
vsize_1000: weightVsizeFees['1000'] || 0,
|
||||
vsize_1200: weightVsizeFees['1200'] || 0,
|
||||
vsize_1400: weightVsizeFees['1400'] || 0,
|
||||
vsize_1600: weightVsizeFees['1600'] || 0,
|
||||
vsize_1800: weightVsizeFees['1800'] || 0,
|
||||
vsize_2000: weightVsizeFees['2000'] || 0,
|
||||
});
|
||||
}
|
||||
|
||||
private async $create(statistics: IMempoolStats): Promise<void> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `INSERT INTO statistics(
|
||||
added,
|
||||
unconfirmed_transactions,
|
||||
tx_per_second,
|
||||
vbytes_per_second,
|
||||
mempool_byte_weight,
|
||||
fee_data,
|
||||
total_fee,
|
||||
vsize_1,
|
||||
vsize_2,
|
||||
vsize_3,
|
||||
vsize_4,
|
||||
vsize_5,
|
||||
vsize_6,
|
||||
vsize_8,
|
||||
vsize_10,
|
||||
vsize_12,
|
||||
vsize_15,
|
||||
vsize_20,
|
||||
vsize_30,
|
||||
vsize_40,
|
||||
vsize_50,
|
||||
vsize_60,
|
||||
vsize_70,
|
||||
vsize_80,
|
||||
vsize_90,
|
||||
vsize_100,
|
||||
vsize_125,
|
||||
vsize_150,
|
||||
vsize_175,
|
||||
vsize_200,
|
||||
vsize_250,
|
||||
vsize_300,
|
||||
vsize_350,
|
||||
vsize_400,
|
||||
vsize_500,
|
||||
vsize_600,
|
||||
vsize_700,
|
||||
vsize_800,
|
||||
vsize_900,
|
||||
vsize_1000,
|
||||
vsize_1200,
|
||||
vsize_1400,
|
||||
vsize_1600,
|
||||
vsize_1800,
|
||||
vsize_2000
|
||||
)
|
||||
VALUES (${statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
const params: (string | number)[] = [
|
||||
statistics.unconfirmed_transactions,
|
||||
statistics.tx_per_second,
|
||||
statistics.vbytes_per_second,
|
||||
statistics.mempool_byte_weight,
|
||||
statistics.fee_data,
|
||||
statistics.total_fee,
|
||||
statistics.vsize_1,
|
||||
statistics.vsize_2,
|
||||
statistics.vsize_3,
|
||||
statistics.vsize_4,
|
||||
statistics.vsize_5,
|
||||
statistics.vsize_6,
|
||||
statistics.vsize_8,
|
||||
statistics.vsize_10,
|
||||
statistics.vsize_12,
|
||||
statistics.vsize_15,
|
||||
statistics.vsize_20,
|
||||
statistics.vsize_30,
|
||||
statistics.vsize_40,
|
||||
statistics.vsize_50,
|
||||
statistics.vsize_60,
|
||||
statistics.vsize_70,
|
||||
statistics.vsize_80,
|
||||
statistics.vsize_90,
|
||||
statistics.vsize_100,
|
||||
statistics.vsize_125,
|
||||
statistics.vsize_150,
|
||||
statistics.vsize_175,
|
||||
statistics.vsize_200,
|
||||
statistics.vsize_250,
|
||||
statistics.vsize_300,
|
||||
statistics.vsize_350,
|
||||
statistics.vsize_400,
|
||||
statistics.vsize_500,
|
||||
statistics.vsize_600,
|
||||
statistics.vsize_700,
|
||||
statistics.vsize_800,
|
||||
statistics.vsize_900,
|
||||
statistics.vsize_1000,
|
||||
statistics.vsize_1200,
|
||||
statistics.vsize_1400,
|
||||
statistics.vsize_1600,
|
||||
statistics.vsize_1800,
|
||||
statistics.vsize_2000,
|
||||
];
|
||||
await connection.query(query, params);
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
console.log('$create() error', e);
|
||||
}
|
||||
}
|
||||
|
||||
public async $listLatestFromId(fromId: number): Promise<IMempoolStats[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT * FROM statistics WHERE id > ? ORDER BY id DESC`;
|
||||
const [rows] = await connection.query<any>(query, [fromId]);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.log('$listLatestFromId() error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getQueryForDays(days: number, groupBy: number) {
|
||||
|
||||
return `SELECT id, added, unconfirmed_transactions,
|
||||
AVG(tx_per_second) AS tx_per_second,
|
||||
AVG(vbytes_per_second) AS vbytes_per_second,
|
||||
AVG(vsize_1) AS vsize_1,
|
||||
AVG(vsize_2) AS vsize_2,
|
||||
AVG(vsize_3) AS vsize_3,
|
||||
AVG(vsize_4) AS vsize_4,
|
||||
AVG(vsize_5) AS vsize_5,
|
||||
AVG(vsize_6) AS vsize_6,
|
||||
AVG(vsize_8) AS vsize_8,
|
||||
AVG(vsize_10) AS vsize_10,
|
||||
AVG(vsize_12) AS vsize_12,
|
||||
AVG(vsize_15) AS vsize_15,
|
||||
AVG(vsize_20) AS vsize_20,
|
||||
AVG(vsize_30) AS vsize_30,
|
||||
AVG(vsize_40) AS vsize_40,
|
||||
AVG(vsize_50) AS vsize_50,
|
||||
AVG(vsize_60) AS vsize_60,
|
||||
AVG(vsize_70) AS vsize_70,
|
||||
AVG(vsize_80) AS vsize_80,
|
||||
AVG(vsize_90) AS vsize_90,
|
||||
AVG(vsize_100) AS vsize_100,
|
||||
AVG(vsize_125) AS vsize_125,
|
||||
AVG(vsize_150) AS vsize_150,
|
||||
AVG(vsize_175) AS vsize_175,
|
||||
AVG(vsize_200) AS vsize_200,
|
||||
AVG(vsize_250) AS vsize_250,
|
||||
AVG(vsize_300) AS vsize_300,
|
||||
AVG(vsize_350) AS vsize_350,
|
||||
AVG(vsize_400) AS vsize_400,
|
||||
AVG(vsize_500) AS vsize_500,
|
||||
AVG(vsize_600) AS vsize_600,
|
||||
AVG(vsize_700) AS vsize_700,
|
||||
AVG(vsize_800) AS vsize_800,
|
||||
AVG(vsize_900) AS vsize_900,
|
||||
AVG(vsize_1000) AS vsize_1000,
|
||||
AVG(vsize_1200) AS vsize_1200,
|
||||
AVG(vsize_1400) AS vsize_1400,
|
||||
AVG(vsize_1600) AS vsize_1600,
|
||||
AVG(vsize_1800) AS vsize_1800,
|
||||
AVG(vsize_2000) AS vsize_2000 FROM statistics GROUP BY UNIX_TIMESTAMP(added) DIV ${groupBy} ORDER BY id DESC LIMIT ${days}`;
|
||||
}
|
||||
|
||||
public async $list2H(): Promise<IMempoolStats[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT * FROM statistics ORDER BY id DESC LIMIT 120`;
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.log('$list2H() error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async $list24H(): Promise<IMempoolStats[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(120, 720);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async $list1W(): Promise<IMempoolStats[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(120, 5040);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.log('$list1W() error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async $list1M(): Promise<IMempoolStats[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(120, 20160);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.log('$list1M() error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async $list3M(): Promise<IMempoolStats[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(120, 60480);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.log('$list3M() error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async $list6M(): Promise<IMempoolStats[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(120, 120960);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.log('$list6M() error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new Statistics();
|
26
backend/src/database.ts
Normal file
@ -0,0 +1,26 @@
|
||||
const config = require('../mempool-config.json');
|
||||
import { createPool } from 'mysql2/promise';
|
||||
|
||||
export class DB {
|
||||
static pool = createPool({
|
||||
host: config.DB_HOST,
|
||||
port: config.DB_PORT,
|
||||
database: config.DB_DATABASE,
|
||||
user: config.DB_USER,
|
||||
password: config.DB_PASSWORD,
|
||||
connectionLimit: 10,
|
||||
supportBigNumbers: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkDbConnection() {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
console.log('MySQL connection established.');
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
console.log('Could not connect to MySQL.');
|
||||
console.log(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
231
backend/src/index.ts
Normal file
@ -0,0 +1,231 @@
|
||||
const config = require('../mempool-config.json');
|
||||
import * as fs from 'fs';
|
||||
import * as express from 'express';
|
||||
import * as compression from 'compression';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as WebSocket from 'ws';
|
||||
|
||||
import bitcoinApi from './api/bitcoin-api-wrapper';
|
||||
import diskCache from './api/disk-cache';
|
||||
import memPool from './api/mempool';
|
||||
import blocks from './api/blocks';
|
||||
import projectedBlocks from './api/projected-blocks';
|
||||
import statistics from './api/statistics';
|
||||
import { IBlock, IMempool } from './interfaces';
|
||||
|
||||
import routes from './routes';
|
||||
import fiatConversion from './api/fiat-conversion';
|
||||
|
||||
class MempoolSpace {
|
||||
private wss: WebSocket.Server;
|
||||
private server: https.Server | http.Server;
|
||||
private app: any;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.app
|
||||
.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
next();
|
||||
})
|
||||
.use(compression());
|
||||
if (config.ENV === 'dev') {
|
||||
this.server = http.createServer(this.app);
|
||||
this.wss = new WebSocket.Server({ server: this.server });
|
||||
} else {
|
||||
const credentials = {
|
||||
cert: fs.readFileSync('/etc/letsencrypt/live/mempool.space/fullchain.pem'),
|
||||
key: fs.readFileSync('/etc/letsencrypt/live/mempool.space/privkey.pem'),
|
||||
};
|
||||
this.server = https.createServer(credentials, this.app);
|
||||
this.wss = new WebSocket.Server({ server: this.server });
|
||||
}
|
||||
|
||||
this.setUpRoutes();
|
||||
this.setUpWebsocketHandling();
|
||||
this.setUpMempoolCache();
|
||||
this.runMempoolIntervalFunctions();
|
||||
|
||||
statistics.startStatistics();
|
||||
fiatConversion.startService();
|
||||
|
||||
this.server.listen(8999, () => {
|
||||
console.log(`Server started on port 8999 :)`);
|
||||
});
|
||||
}
|
||||
|
||||
private async runMempoolIntervalFunctions() {
|
||||
await blocks.updateBlocks();
|
||||
await memPool.getMemPoolInfo();
|
||||
await memPool.updateMempool();
|
||||
setTimeout(this.runMempoolIntervalFunctions.bind(this), config.MEMPOOL_REFRESH_RATE_MS);
|
||||
}
|
||||
|
||||
private setUpMempoolCache() {
|
||||
const cacheData = diskCache.loadData();
|
||||
if (cacheData) {
|
||||
memPool.setMempool(JSON.parse(cacheData));
|
||||
}
|
||||
|
||||
process.on('SIGINT', (options) => {
|
||||
console.log('SIGINT');
|
||||
diskCache.saveData(JSON.stringify(memPool.getMempool()));
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
|
||||
private setUpWebsocketHandling() {
|
||||
this.wss.on('connection', (client: WebSocket) => {
|
||||
let theBlocks = blocks.getBlocks();
|
||||
theBlocks = theBlocks.concat([]).splice(theBlocks.length - config.INITIAL_BLOCK_AMOUNT);
|
||||
|
||||
const formatedBlocks = theBlocks.map((b) => blocks.formatBlock(b));
|
||||
|
||||
client.send(JSON.stringify({
|
||||
'mempoolInfo': memPool.getMempoolInfo(),
|
||||
'blocks': formatedBlocks,
|
||||
'projectedBlocks': projectedBlocks.getProjectedBlocks(),
|
||||
'txPerSecond': memPool.getTxPerSecond(),
|
||||
'vBytesPerSecond': memPool.getVBytesPerSecond(),
|
||||
'conversions': fiatConversion.getTickers()['BTCUSD'],
|
||||
}));
|
||||
|
||||
client.on('message', async (message: any) => {
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message);
|
||||
if (parsedMessage.action === 'track-tx' && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) {
|
||||
const tx = await memPool.getRawTransaction(parsedMessage.txId);
|
||||
if (tx) {
|
||||
console.log('Now tracking: ' + parsedMessage.txId);
|
||||
client['trackingTx'] = true;
|
||||
client['txId'] = parsedMessage.txId;
|
||||
client['tx'] = tx;
|
||||
|
||||
if (tx.blockhash) {
|
||||
const currentBlocks = blocks.getBlocks();
|
||||
const foundBlock = currentBlocks.find((block) => block.tx && block.tx.some((i: string) => i === parsedMessage.txId));
|
||||
if (foundBlock) {
|
||||
console.log('Found block by looking in local cache');
|
||||
client['blockHeight'] = foundBlock.height;
|
||||
} else {
|
||||
const theBlock = await bitcoinApi.getBlock(tx.blockhash);
|
||||
if (theBlock) {
|
||||
client['blockHeight'] = theBlock.height;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
client['blockHeight'] = 0;
|
||||
}
|
||||
client.send(JSON.stringify({
|
||||
'projectedBlocks': projectedBlocks.getProjectedBlocks(client['txId']),
|
||||
'track-tx': {
|
||||
tracking: true,
|
||||
blockHeight: client['blockHeight'],
|
||||
tx: client['tx'],
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
console.log('TX NOT FOUND, NOT TRACKING');
|
||||
client.send(JSON.stringify({
|
||||
'track-tx': {
|
||||
tracking: false,
|
||||
blockHeight: 0,
|
||||
message: 'not-found',
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (parsedMessage.action === 'stop-tracking-tx') {
|
||||
console.log('STOP TRACKING');
|
||||
client['trackingTx'] = false;
|
||||
client.send(JSON.stringify({
|
||||
'track-tx': {
|
||||
tracking: false,
|
||||
blockHeight: 0,
|
||||
message: 'not-found',
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
client['trackingTx'] = false;
|
||||
});
|
||||
});
|
||||
|
||||
blocks.setNewBlockCallback((block: IBlock) => {
|
||||
const formattedBlocks = blocks.formatBlock(block);
|
||||
|
||||
this.wss.clients.forEach((client) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (client['trackingTx'] === true && client['blockHeight'] === 0) {
|
||||
if (block.tx.some((tx) => tx === client['txId'])) {
|
||||
client['blockHeight'] = block.height;
|
||||
}
|
||||
}
|
||||
|
||||
client.send(JSON.stringify({
|
||||
'block': formattedBlocks,
|
||||
'track-tx': {
|
||||
tracking: client['trackingTx'] || false,
|
||||
blockHeight: client['blockHeight'],
|
||||
}
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
memPool.setMempoolChangedCallback((newMempool: IMempool) => {
|
||||
projectedBlocks.updateProjectedBlocks(newMempool);
|
||||
|
||||
let pBlocks = projectedBlocks.getProjectedBlocks();
|
||||
const mempoolInfo = memPool.getMempoolInfo();
|
||||
const txPerSecond = memPool.getTxPerSecond();
|
||||
const vBytesPerSecond = memPool.getVBytesPerSecond();
|
||||
|
||||
this.wss.clients.forEach((client: WebSocket) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (client['trackingTx'] && client['blockHeight'] === 0) {
|
||||
pBlocks = projectedBlocks.getProjectedBlocks(client['txId']);
|
||||
}
|
||||
|
||||
client.send(JSON.stringify({
|
||||
'projectedBlocks': pBlocks,
|
||||
'mempoolInfo': mempoolInfo,
|
||||
'txPerSecond': txPerSecond,
|
||||
'vBytesPerSecond': vBytesPerSecond,
|
||||
'track-tx': {
|
||||
tracking: client['trackingTx'] || false,
|
||||
blockHeight: client['blockHeight'],
|
||||
}
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setUpRoutes() {
|
||||
this.app
|
||||
.get(config.API_ENDPOINT + 'transactions/height/:id', routes.$getgetTransactionsForBlock)
|
||||
.get(config.API_ENDPOINT + 'transactions/projected/:id', routes.getgetTransactionsForProjectedBlock)
|
||||
.get(config.API_ENDPOINT + 'fees/recommended', routes.getRecommendedFees)
|
||||
.get(config.API_ENDPOINT + 'statistics/live', routes.getLiveResult)
|
||||
.get(config.API_ENDPOINT + 'statistics/2h', routes.get2HStatistics)
|
||||
.get(config.API_ENDPOINT + 'statistics/24h', routes.get24HStatistics)
|
||||
.get(config.API_ENDPOINT + 'statistics/1w', routes.get1WHStatistics)
|
||||
.get(config.API_ENDPOINT + 'statistics/1m', routes.get1MStatistics)
|
||||
.get(config.API_ENDPOINT + 'statistics/3m', routes.get3MStatistics)
|
||||
.get(config.API_ENDPOINT + 'statistics/6m', routes.get6MStatistics)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
const mempoolSpace = new MempoolSpace();
|
151
backend/src/interfaces.ts
Normal file
@ -0,0 +1,151 @@
|
||||
export interface IMempoolInfo {
|
||||
size: number;
|
||||
bytes: number;
|
||||
usage: number;
|
||||
maxmempool: number;
|
||||
mempoolminfee: number;
|
||||
minrelaytxfee: number;
|
||||
}
|
||||
|
||||
export interface ITransaction {
|
||||
txid: string;
|
||||
hash: string;
|
||||
version: number;
|
||||
size: number;
|
||||
vsize: number;
|
||||
locktime: number;
|
||||
vin: Vin[];
|
||||
vout: Vout[];
|
||||
hex: string;
|
||||
fee: number;
|
||||
feePerWeightUnit: number;
|
||||
feePerVsize: number;
|
||||
blockhash?: string;
|
||||
confirmations?: number;
|
||||
time?: number;
|
||||
blocktime?: number;
|
||||
totalOut?: number;
|
||||
}
|
||||
|
||||
export interface IBlock {
|
||||
hash: string;
|
||||
confirmations: number;
|
||||
strippedsize: number;
|
||||
size: number;
|
||||
weight: number;
|
||||
height: number;
|
||||
version: number;
|
||||
versionHex: string;
|
||||
merkleroot: string;
|
||||
tx: any;
|
||||
time: number;
|
||||
mediantime: number;
|
||||
nonce: number;
|
||||
bits: string;
|
||||
difficulty: number;
|
||||
chainwork: string;
|
||||
nTx: number;
|
||||
previousblockhash: string;
|
||||
fees: number;
|
||||
|
||||
minFee?: number;
|
||||
maxFee?: number;
|
||||
medianFee?: number;
|
||||
}
|
||||
|
||||
interface ScriptSig {
|
||||
asm: string;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
interface Vin {
|
||||
txid: string;
|
||||
vout: number;
|
||||
scriptSig: ScriptSig;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
interface ScriptPubKey {
|
||||
asm: string;
|
||||
hex: string;
|
||||
reqSigs: number;
|
||||
type: string;
|
||||
addresses: string[];
|
||||
}
|
||||
|
||||
interface Vout {
|
||||
value: number;
|
||||
n: number;
|
||||
scriptPubKey: ScriptPubKey;
|
||||
}
|
||||
|
||||
export interface IMempoolStats {
|
||||
id?: number;
|
||||
added: string;
|
||||
unconfirmed_transactions: number;
|
||||
tx_per_second: number;
|
||||
vbytes_per_second: number;
|
||||
total_fee: number;
|
||||
mempool_byte_weight: number;
|
||||
fee_data: string;
|
||||
|
||||
vsize_1: number;
|
||||
vsize_2: number;
|
||||
vsize_3: number;
|
||||
vsize_4: number;
|
||||
vsize_5: number;
|
||||
vsize_6: number;
|
||||
vsize_8: number;
|
||||
vsize_10: number;
|
||||
vsize_12: number;
|
||||
vsize_15: number;
|
||||
vsize_20: number;
|
||||
vsize_30: number;
|
||||
vsize_40: number;
|
||||
vsize_50: number;
|
||||
vsize_60: number;
|
||||
vsize_70: number;
|
||||
vsize_80: number;
|
||||
vsize_90: number;
|
||||
vsize_100: number;
|
||||
vsize_125: number;
|
||||
vsize_150: number;
|
||||
vsize_175: number;
|
||||
vsize_200: number;
|
||||
vsize_250: number;
|
||||
vsize_300: number;
|
||||
vsize_350: number;
|
||||
vsize_400: number;
|
||||
vsize_500: number;
|
||||
vsize_600: number;
|
||||
vsize_700: number;
|
||||
vsize_800: number;
|
||||
vsize_900: number;
|
||||
vsize_1000: number;
|
||||
vsize_1200: number;
|
||||
vsize_1400: number;
|
||||
vsize_1600: number;
|
||||
vsize_1800: number;
|
||||
vsize_2000: number;
|
||||
}
|
||||
|
||||
export interface IProjectedBlockInternal extends IProjectedBlock {
|
||||
txIds: string[];
|
||||
txFeePerVsizes: number[];
|
||||
}
|
||||
|
||||
export interface IProjectedBlock {
|
||||
blockSize: number;
|
||||
blockWeight: number;
|
||||
maxFee: number;
|
||||
maxWeightFee: number;
|
||||
medianFee: number;
|
||||
minFee: number;
|
||||
minWeightFee: number;
|
||||
nTx: number;
|
||||
fees: number;
|
||||
hasMyTxId?: boolean;
|
||||
}
|
||||
|
||||
export interface IMempool { [txid: string]: ITransaction; }
|
||||
|
63
backend/src/routes.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import statistics from './api/statistics';
|
||||
import feeApi from './api/fee-api';
|
||||
import projectedBlocks from './api/projected-blocks';
|
||||
|
||||
class Routes {
|
||||
constructor() {}
|
||||
|
||||
public async getLiveResult(req, res) {
|
||||
const result = await statistics.$listLatestFromId(req.query.lastId);
|
||||
res.send(result);
|
||||
}
|
||||
|
||||
public async get2HStatistics(req, res) {
|
||||
const result = await statistics.$list2H();
|
||||
res.send(result);
|
||||
}
|
||||
|
||||
public async get24HStatistics(req, res) {
|
||||
const result = await statistics.$list24H();
|
||||
res.send(result);
|
||||
}
|
||||
|
||||
public async get1WHStatistics(req, res) {
|
||||
const result = await statistics.$list1W();
|
||||
res.send(result);
|
||||
}
|
||||
|
||||
public async get1MStatistics(req, res) {
|
||||
const result = await statistics.$list1M();
|
||||
res.send(result);
|
||||
}
|
||||
|
||||
public async get3MStatistics(req, res) {
|
||||
const result = await statistics.$list3M();
|
||||
res.send(result);
|
||||
}
|
||||
|
||||
public async get6MStatistics(req, res) {
|
||||
const result = await statistics.$list6M();
|
||||
res.send(result);
|
||||
}
|
||||
|
||||
public async getRecommendedFees(req, res) {
|
||||
const result = feeApi.getRecommendedFee();
|
||||
res.send(result);
|
||||
}
|
||||
|
||||
public async $getgetTransactionsForBlock(req, res) {
|
||||
const result = await feeApi.$getTransactionsForBlock(req.params.id);
|
||||
res.send(result);
|
||||
}
|
||||
|
||||
public async getgetTransactionsForProjectedBlock(req, res) {
|
||||
try {
|
||||
const result = await projectedBlocks.getProjectedBlockFeesForBlock(req.params.id);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Routes();
|
20
backend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2015",
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"sourceMap": false,
|
||||
"outDir": "dist",
|
||||
"moduleResolution": "node",
|
||||
"typeRoots": [
|
||||
"node_modules/@types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"dist/**"
|
||||
]
|
||||
}
|
137
backend/tslint.json
Normal file
@ -0,0 +1,137 @@
|
||||
{
|
||||
"rules": {
|
||||
"arrow-return-shorthand": true,
|
||||
"callable-types": true,
|
||||
"class-name": true,
|
||||
"comment-format": [
|
||||
true,
|
||||
"check-space"
|
||||
],
|
||||
"curly": true,
|
||||
"deprecation": {
|
||||
"severity": "warn"
|
||||
},
|
||||
"eofline": true,
|
||||
"forin": true,
|
||||
"import-blacklist": [
|
||||
true,
|
||||
"rxjs",
|
||||
"rxjs/Rx"
|
||||
],
|
||||
"import-spacing": true,
|
||||
"indent": [
|
||||
true,
|
||||
"spaces"
|
||||
],
|
||||
"interface-over-type-literal": true,
|
||||
"label-position": true,
|
||||
"max-line-length": [
|
||||
true,
|
||||
140
|
||||
],
|
||||
"member-access": false,
|
||||
"member-ordering": [
|
||||
true,
|
||||
{
|
||||
"order": [
|
||||
"static-field",
|
||||
"instance-field",
|
||||
"static-method",
|
||||
"instance-method"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-arg": true,
|
||||
"no-bitwise": true,
|
||||
"no-console": [
|
||||
true,
|
||||
"debug",
|
||||
"info",
|
||||
"time",
|
||||
"timeEnd",
|
||||
"trace"
|
||||
],
|
||||
"no-construct": true,
|
||||
"no-debugger": true,
|
||||
"no-duplicate-super": true,
|
||||
"no-empty": false,
|
||||
"no-empty-interface": true,
|
||||
"no-eval": true,
|
||||
"no-inferrable-types": false,
|
||||
"no-misused-new": true,
|
||||
"no-non-null-assertion": true,
|
||||
"no-shadowed-variable": true,
|
||||
"no-string-literal": false,
|
||||
"no-string-throw": true,
|
||||
"no-switch-case-fall-through": true,
|
||||
"no-trailing-whitespace": true,
|
||||
"no-unnecessary-initializer": true,
|
||||
"no-unused-expression": true,
|
||||
"no-use-before-declare": true,
|
||||
"no-var-keyword": true,
|
||||
"object-literal-sort-keys": false,
|
||||
"one-line": [
|
||||
true,
|
||||
"check-open-brace",
|
||||
"check-catch",
|
||||
"check-else",
|
||||
"check-whitespace"
|
||||
],
|
||||
"prefer-const": true,
|
||||
"quotemark": [
|
||||
true,
|
||||
"single"
|
||||
],
|
||||
"radix": true,
|
||||
"semicolon": [
|
||||
true,
|
||||
"always"
|
||||
],
|
||||
"triple-equals": [
|
||||
true,
|
||||
"allow-null-check"
|
||||
],
|
||||
"typedef-whitespace": [
|
||||
true,
|
||||
{
|
||||
"call-signature": "nospace",
|
||||
"index-signature": "nospace",
|
||||
"parameter": "nospace",
|
||||
"property-declaration": "nospace",
|
||||
"variable-declaration": "nospace"
|
||||
}
|
||||
],
|
||||
"unified-signatures": true,
|
||||
"variable-name": false,
|
||||
"whitespace": [
|
||||
true,
|
||||
"check-branch",
|
||||
"check-decl",
|
||||
"check-operator",
|
||||
"check-separator",
|
||||
"check-type"
|
||||
],
|
||||
"directive-selector": [
|
||||
true,
|
||||
"attribute",
|
||||
"app",
|
||||
"camelCase"
|
||||
],
|
||||
"component-selector": [
|
||||
true,
|
||||
"element",
|
||||
"app",
|
||||
"kebab-case"
|
||||
],
|
||||
"no-output-on-prefix": true,
|
||||
"use-input-property-decorator": true,
|
||||
"use-output-property-decorator": true,
|
||||
"use-host-property-decorator": true,
|
||||
"no-input-rename": true,
|
||||
"no-output-rename": true,
|
||||
"use-life-cycle-interface": true,
|
||||
"use-pipe-transform-interface": true,
|
||||
"component-class-suffix": true,
|
||||
"directive-class-suffix": true
|
||||
}
|
||||
}
|
13
frontend/.editorconfig
Normal file
@ -0,0 +1,13 @@
|
||||
# Editor configuration, see http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
39
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
127
frontend/angular.json
Normal file
@ -0,0 +1,127 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"mempool": {
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"projectType": "application",
|
||||
"prefix": "app",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"styleext": "scss"
|
||||
}
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/mempool",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "src/tsconfig.app.json",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/.htaccess"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractCss": true,
|
||||
"namedChunks": false,
|
||||
"aot": true,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "mempool:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "mempool:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "mempool:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "src/tsconfig.spec.json",
|
||||
"karmaConfig": "src/karma.conf.js",
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": [],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": [
|
||||
"src/tsconfig.app.json",
|
||||
"src/tsconfig.spec.json"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mempool-e2e": {
|
||||
"root": "e2e/",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "mempool:serve"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": "e2e/tsconfig.e2e.json",
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultProject": "mempool"
|
||||
}
|
28
frontend/e2e/protractor.conf.js
Normal file
@ -0,0 +1,28 @@
|
||||
// Protractor configuration file, see link for more information
|
||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
||||
|
||||
const { SpecReporter } = require('jasmine-spec-reporter');
|
||||
|
||||
exports.config = {
|
||||
allScriptsTimeout: 11000,
|
||||
specs: [
|
||||
'./src/**/*.e2e-spec.ts'
|
||||
],
|
||||
capabilities: {
|
||||
'browserName': 'chrome'
|
||||
},
|
||||
directConnect: true,
|
||||
baseUrl: 'http://localhost:4200/',
|
||||
framework: 'jasmine',
|
||||
jasmineNodeOpts: {
|
||||
showColors: true,
|
||||
defaultTimeoutInterval: 30000,
|
||||
print: function() {}
|
||||
},
|
||||
onPrepare() {
|
||||
require('ts-node').register({
|
||||
project: require('path').join(__dirname, './tsconfig.e2e.json')
|
||||
});
|
||||
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
|
||||
}
|
||||
};
|
14
frontend/e2e/src/app.e2e-spec.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { AppPage } from './app.po';
|
||||
|
||||
describe('workspace-project App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getParagraphText()).toEqual('Welcome to app!');
|
||||
});
|
||||
});
|
11
frontend/e2e/src/app.po.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { browser, by, element } from 'protractor';
|
||||
|
||||
export class AppPage {
|
||||
navigateTo() {
|
||||
return browser.get('/');
|
||||
}
|
||||
|
||||
getParagraphText() {
|
||||
return element(by.css('app-root h1')).getText();
|
||||
}
|
||||
}
|
13
frontend/e2e/tsconfig.e2e.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/app",
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
53
frontend/package.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "mempool",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --aot --proxy-config proxy.conf.json",
|
||||
"build": "ng build --prod --vendorChunk=false --build-optimizer=true",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^8.0.0",
|
||||
"@angular/common": "^8.0.0",
|
||||
"@angular/compiler": "^8.0.0",
|
||||
"@angular/core": "^8.0.0",
|
||||
"@angular/forms": "^8.0.0",
|
||||
"@angular/platform-browser": "^8.0.0",
|
||||
"@angular/platform-browser-dynamic": "^8.0.0",
|
||||
"@angular/router": "^8.0.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^3.3.1",
|
||||
"bootstrap": "^4.3.1",
|
||||
"chartist": "^0.11.2",
|
||||
"core-js": "^2.6.9",
|
||||
"ng-chartist": "^2.0.0-beta.1",
|
||||
"rxjs": "^6.5.2",
|
||||
"tslib": "^1.9.0",
|
||||
"zone.js": "~0.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "~0.800.0",
|
||||
"@angular/cli": "~8.0.2",
|
||||
"@angular/compiler-cli": "^8.0.0",
|
||||
"@angular/language-service": "^8.0.0",
|
||||
"@types/chartist": "^0.9.46",
|
||||
"@types/jasmine": "^2.8.16",
|
||||
"@types/jasminewd2": "^2.0.6",
|
||||
"@types/node": "~8.9.4",
|
||||
"codelyzer": "~5.1.0",
|
||||
"jasmine-core": "~2.99.1",
|
||||
"jasmine-spec-reporter": "~4.2.1",
|
||||
"karma": "~1.7.1",
|
||||
"karma-chrome-launcher": "~2.2.0",
|
||||
"karma-coverage-istanbul-reporter": "~1.4.2",
|
||||
"karma-jasmine": "~1.1.1",
|
||||
"karma-jasmine-html-reporter": "^0.2.2",
|
||||
"protractor": "~5.3.0",
|
||||
"ts-node": "~7.0.0",
|
||||
"tslint": "~5.15.0",
|
||||
"typescript": "~3.4.3"
|
||||
}
|
||||
}
|
6
frontend/proxy.conf.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8999/",
|
||||
"secure": false
|
||||
}
|
||||
}
|
7
frontend/src/.htaccess
Normal file
@ -0,0 +1,7 @@
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_FILENAME} -s [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -l [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^.*$ - [NC,L]
|
||||
|
||||
RewriteRule ^(.*) /index.html [NC,L]
|
41
frontend/src/app/about/about.component.html
Normal file
@ -0,0 +1,41 @@
|
||||
<div class="text-center">
|
||||
<img src="./assets/mempool-tube.png" width="63" height="63" />
|
||||
<br /><br />
|
||||
|
||||
<h2>About</h2>
|
||||
|
||||
<p>Mempool.Space is a realtime Bitcoin blockchain visualizer and statistics website focused on SegWit.</p>
|
||||
<p>Created by <a href="http://t.me/softcrypto">@softcrypto</a> (Telegram). <a href="https://twitter.com/softcrypt0">@softcrypt0</a> (Twitter).
|
||||
<br />Designed by <a href="https://emeraldo.io">emeraldo.io</a>.</p>
|
||||
|
||||
|
||||
<h2>Fee API</h2>
|
||||
|
||||
<div class="col-4 mx-auto">
|
||||
<input class="form-control" type="text" value="https://mempool.space:8999/api/v1/fees/recommended" readonly>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<h1>Donate</h1>
|
||||
<h3>Segwit native</h3>
|
||||
<img src="./assets/btc-qr-code-segwit.png" width="200" height="200" />
|
||||
<br />
|
||||
bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t
|
||||
|
||||
<br /><br />
|
||||
<h3>Segwit compatibility</h3>
|
||||
<img src="./assets/btc-qr-code.png" width="200" height="200" />
|
||||
<br />
|
||||
3Ccig4G4u8hbExnxBJHeE5ZmxxWxvEQ65f
|
||||
|
||||
|
||||
<br /><br />
|
||||
|
||||
<h3>PayNym</h3>
|
||||
<img src="./assets/paynym-code.png" width="200" height="200" />
|
||||
<br />
|
||||
<p style="word-wrap: break-word; overflow-wrap: break-word;max-width: 300px; text-align: center; margin: auto;">
|
||||
PM8TJZWDn1XbYmVVMR3RP9Kt1BW69VCSLTC12UB8iWUiKcEBJsxB4UUKBMJxc3LVaxtU5d524sLFrTy9kFuyPQ73QkEagGcMfCE6M38E5C67EF8KAqvS
|
||||
</p>
|
||||
</div>
|
0
frontend/src/app/about/about.component.scss
Normal file
15
frontend/src/app/about/about.component.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about',
|
||||
templateUrl: './about.component.html',
|
||||
styleUrls: ['./about.component.scss']
|
||||
})
|
||||
export class AboutComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
}
|
40
frontend/src/app/app-routing.module.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { BlockchainComponent } from './blockchain/blockchain.component';
|
||||
import { AboutComponent } from './about/about.component';
|
||||
import { StatisticsComponent } from './statistics/statistics.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
children: [],
|
||||
component: BlockchainComponent
|
||||
},
|
||||
{
|
||||
path: 'tx/:id',
|
||||
children: [],
|
||||
component: BlockchainComponent
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
children: [],
|
||||
component: AboutComponent
|
||||
},
|
||||
{
|
||||
path: 'statistics',
|
||||
component: StatisticsComponent,
|
||||
},
|
||||
{
|
||||
path: 'graphs',
|
||||
component: StatisticsComponent,
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
}
|
||||
];
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
32
frontend/src/app/app.component.html
Normal file
@ -0,0 +1,32 @@
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
|
||||
<a class="navbar-brand" routerLink="/"><img src="/assets/mempool-space-logo.png" width="180" class="logo"> <span class="badge badge-warning" style="margin-left: 10px;" *ngIf="isOffline">Offline</span></a>
|
||||
|
||||
<button class="navbar-toggler" type="button" (click)="collapse()" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="navbar-collapse collapse" id="navbarCollapse" [ngClass]="{'show': navCollapsed}">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item" routerLinkActive="active" [ngClass]="{'active': txActive.isActive}" [routerLinkActiveOptions]="{exact: true}">
|
||||
<a class="nav-link" routerLink="/" (click)="collapse()">Blocks</a>
|
||||
<a class="nav-link" routerLink="/tx" routerLinkActive #txActive="routerLinkActive" style="display: none;"></a>
|
||||
</li>
|
||||
<li class="nav-item" routerLinkActive="active">
|
||||
<a class="nav-link" routerLink="/graphs" (click)="collapse()">Graphs</a>
|
||||
</li>
|
||||
<li class="nav-item" routerLinkActive="active">
|
||||
<a class="nav-link" routerLink="/about" (click)="collapse()">About</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form [formGroup]="searchForm" class="form-inline mt-2 mt-md-0 mr-4" (submit)="searchForm.valid && search()" novalidate>
|
||||
<input formControlName="txId" required style="width: 300px;" class="form-control mr-sm-2" type="text" placeholder="Track transaction (TXID)" aria-label="Search">
|
||||
<button class="btn btn-primary my-2 my-sm-0" type="submit">Track</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<br />
|
||||
|
||||
<router-outlet></router-outlet>
|
28
frontend/src/app/app.component.scss
Normal file
@ -0,0 +1,28 @@
|
||||
li.nav-item.active {
|
||||
background-color: #653b9c;
|
||||
}
|
||||
|
||||
li.nav-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0rem 1rem;
|
||||
}
|
||||
li.nav-item {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
li.nav-item a {
|
||||
color: #ffffff;
|
||||
}
|
52
frontend/src/app/app.component.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { MemPoolService } from './services/mem-pool.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
navCollapsed = false;
|
||||
isOffline = false;
|
||||
searchForm: FormGroup;
|
||||
|
||||
constructor(
|
||||
private memPoolService: MemPoolService,
|
||||
private router: Router,
|
||||
private formBuilder: FormBuilder,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.searchForm = this.formBuilder.group({
|
||||
txId: ['', Validators.pattern('^[a-fA-F0-9]{64}$')],
|
||||
});
|
||||
|
||||
this.memPoolService.isOffline
|
||||
.subscribe((state) => {
|
||||
this.isOffline = state;
|
||||
});
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
this.navCollapsed = !this.navCollapsed;
|
||||
}
|
||||
|
||||
search() {
|
||||
const txId = this.searchForm.value.txId;
|
||||
if (txId) {
|
||||
if (window.location.pathname === '/' || window.location.pathname.substr(0, 4) === '/tx/') {
|
||||
window.history.pushState({}, '', `/tx/${txId}`);
|
||||
} else {
|
||||
this.router.navigate(['/tx/', txId]);
|
||||
}
|
||||
this.memPoolService.txIdSearch.next(txId);
|
||||
this.searchForm.setValue({
|
||||
txId: '',
|
||||
});
|
||||
this.collapse();
|
||||
}
|
||||
}
|
||||
}
|
45
frontend/src/app/app.module.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { BlockchainComponent } from './blockchain/blockchain.component';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { SharedModule } from './shared/shared.module';
|
||||
import { MemPoolService } from './services/mem-pool.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { FooterComponent } from './footer/footer.component';
|
||||
import { AboutComponent } from './about/about.component';
|
||||
import { TxBubbleComponent } from './tx-bubble/tx-bubble.component';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { BlockModalComponent } from './block-modal/block-modal.component';
|
||||
import { StatisticsComponent } from './statistics/statistics.component';
|
||||
import { ProjectedBlockModalComponent } from './projected-block-modal/projected-block-modal.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
BlockchainComponent,
|
||||
FooterComponent,
|
||||
StatisticsComponent,
|
||||
AboutComponent,
|
||||
TxBubbleComponent,
|
||||
BlockModalComponent,
|
||||
ProjectedBlockModalComponent,
|
||||
],
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
BrowserModule,
|
||||
HttpClientModule,
|
||||
AppRoutingModule,
|
||||
SharedModule,
|
||||
],
|
||||
providers: [
|
||||
MemPoolService,
|
||||
],
|
||||
entryComponents: [
|
||||
BlockModalComponent,
|
||||
ProjectedBlockModalComponent,
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
45
frontend/src/app/block-modal/block-modal.component.html
Normal file
@ -0,0 +1,45 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Fee distribution for block <a href="https://www.blockstream.info/block-height/{{ block.height }}" target="_blank">#{{ block.height }}</a></h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div>
|
||||
<table class="table table-borderless table-sm">
|
||||
<tr>
|
||||
<th>Median fee:</th>
|
||||
<td>~{{ block.medianFee | ceil }} sat/vB <span *ngIf="conversions">(~<span class="green-color">{{ conversions.USD * (block.medianFee/100000000)*250 | currency:'USD':'symbol':'1.2-2' }}</span>/tx)</span></td>
|
||||
<th>Block size:</th>
|
||||
<td>{{ block.size | bytes: 2 }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Fee span:</th>
|
||||
<td class="yellow-color">{{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB</td>
|
||||
<th>Tx count:</th>
|
||||
<td>{{ block.nTx }} transactions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total fees:</th>
|
||||
<td>{{ (block.fees - 12.5) | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * (block.fees - 12.5) | currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
|
||||
<th>Block reward + fees:</th>
|
||||
<td>{{ block.fees | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * block.fees | currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div style="height: 400px;" *ngIf="mempoolVsizeFeesData; else loadingFees">
|
||||
<app-chartist
|
||||
[data]="mempoolVsizeFeesData"
|
||||
[type]="'Bar'"
|
||||
[options]="mempoolVsizeFeesOptions">
|
||||
</app-chartist>
|
||||
</div>
|
||||
<ng-template #loadingFees>
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
7
frontend/src/app/block-modal/block-modal.component.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.yellow-color {
|
||||
color: #ffd800;
|
||||
}
|
||||
|
||||
.green-color {
|
||||
color: #3bcc49;
|
||||
}
|
73
frontend/src/app/block-modal/block-modal.component.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { Component, OnInit, Input } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { IBlock } from '../blockchain/interfaces';
|
||||
import { MemPoolService } from '../services/mem-pool.service';
|
||||
import * as Chartist from 'chartist';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block-modal',
|
||||
templateUrl: './block-modal.component.html',
|
||||
styleUrls: ['./block-modal.component.scss']
|
||||
})
|
||||
export class BlockModalComponent implements OnInit {
|
||||
@Input() block: IBlock;
|
||||
|
||||
mempoolVsizeFeesData: any;
|
||||
mempoolVsizeFeesOptions: any;
|
||||
conversions: any;
|
||||
|
||||
constructor(
|
||||
public activeModal: NgbActiveModal,
|
||||
private apiService: ApiService,
|
||||
private memPoolService: MemPoolService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
this.mempoolVsizeFeesOptions = {
|
||||
showArea: false,
|
||||
showLine: false,
|
||||
fullWidth: false,
|
||||
showPoint: false,
|
||||
low: 0,
|
||||
axisX: {
|
||||
position: 'start',
|
||||
showLabel: false,
|
||||
offset: 0,
|
||||
showGrid: false,
|
||||
},
|
||||
axisY: {
|
||||
position: 'end',
|
||||
scaleMinSpace: 40,
|
||||
showGrid: false,
|
||||
},
|
||||
plugins: [
|
||||
Chartist.plugins.tooltip({
|
||||
tooltipOffset: {
|
||||
x: 15,
|
||||
y: 250
|
||||
},
|
||||
transformTooltipTextFnc: (value: number): any => {
|
||||
return Math.ceil(value) + ' sat/vB';
|
||||
},
|
||||
anchorToPoint: false,
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
this.memPoolService.conversions
|
||||
.subscribe((conversions) => {
|
||||
this.conversions = conversions;
|
||||
});
|
||||
|
||||
this.apiService.listTransactionsForBlock$(this.block.height)
|
||||
.subscribe((data) => {
|
||||
this.mempoolVsizeFeesData = {
|
||||
labels: data.map((x, i) => i),
|
||||
series: [data.map((tx) => tx.fpv)]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
69
frontend/src/app/blockchain/blockchain.component.html
Normal file
@ -0,0 +1,69 @@
|
||||
<div *ngIf="blocks.length === 0" class="text-center">
|
||||
<h3>Loading blocks...</h3>
|
||||
<br>
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
<div *ngIf="blocks.length !== 0 && txTrackingLoading" class="text-center black-background">
|
||||
<h3>Locating transaction...</h3>
|
||||
</div>
|
||||
<div *ngIf="txShowTxNotFound" class="text-center black-background">
|
||||
<h3>Transaction not found!</h3>
|
||||
</div>
|
||||
<div class="text-center" class="blockchain-wrapper">
|
||||
<div class="position-container">
|
||||
|
||||
<div class="projected-blocks-container">
|
||||
<div *ngFor="let projectedBlock of projectedBlocks; let i = index; trackBy: trackByProjectedFn">
|
||||
<div (click)="openProjectedBlockModal(projectedBlock, i);" class="bitcoin-block text-center projected-block" id="projected-block-{{ i }}" [ngStyle]="getStyleForProjectedBlockAtIndex(i)">
|
||||
<div class="block-body" *ngIf="projectedBlocks?.length">
|
||||
<div class="fees">
|
||||
~{{ projectedBlock.medianFee | ceil }} sat/vB
|
||||
<br/>
|
||||
<span class="yellow-color">{{ projectedBlock.minFee | ceil }} - {{ projectedBlock.maxFee | ceil }} sat/vB</span>
|
||||
</div>
|
||||
<div class="block-size">{{ projectedBlock.blockSize | bytes: 2 }}</div>
|
||||
<div class="transaction-count">{{ projectedBlock.nTx }} transactions</div>
|
||||
<div class="time-difference" *ngIf="i !== 3">In ~{{ 10 * i + 10 }} minutes</div>
|
||||
<ng-template [ngIf]="i === 3 && projectedBlocks?.length >= 4 && (projectedBlock.blockWeight / 4000000 | ceil) > 1">
|
||||
<div class="time-difference">+{{ projectedBlock.blockWeight / 4000000 | ceil }} blocks</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<span class="animated-border"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="blocks-container" *ngIf="blocks.length">
|
||||
|
||||
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn" >
|
||||
<div (click)="openBlockModal(block);" class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" [ngStyle]="getStyleForBlock(block)">
|
||||
|
||||
<div class="block-height">
|
||||
<a href="https://www.blockstream.info/block-height/{{ block.height }}" target="_blank">#{{ block.height }}</a>
|
||||
</div>
|
||||
|
||||
<div class="block-body">
|
||||
<div class="fees">
|
||||
~{{ block.medianFee | ceil }} sat/vB
|
||||
<br/>
|
||||
<span class="yellow-color">{{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB</span>
|
||||
</div>
|
||||
|
||||
<div class="block-size">{{ block.size | bytes: 2 }}</div>
|
||||
<div class="transaction-count">{{ block.nTx }} transactions</div>
|
||||
<br /><br />
|
||||
<div class="time-difference">{{ getTimeSinceMined(block) }} ago</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="divider" *ngIf="blocks.length"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<app-tx-bubble *ngIf="blocks?.length && txTrackingTx" [tx]="txTrackingTx" [arrowPosition]="txBubbleArrowPosition" [ngStyle]="txBubbleStyle" [latestBlockHeight]="blocks[0].height" [txTrackingBlockHeight]="txTrackingBlockHeight"></app-tx-bubble>
|
||||
|
||||
<app-footer></app-footer>
|
195
frontend/src/app/blockchain/blockchain.component.scss
Normal file
@ -0,0 +1,195 @@
|
||||
.block-filled {
|
||||
width: 100%;
|
||||
background-color: #aeffb0;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.block-filled .segwit {
|
||||
background-color: #16ca1a;
|
||||
}
|
||||
|
||||
.bitcoin-block {
|
||||
width: 125px;
|
||||
height: 125px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mined-block {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
transition: 1s;
|
||||
}
|
||||
|
||||
.block-size {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.blocks-container {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 40px;
|
||||
}
|
||||
|
||||
.projected-blocks-container {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
left: 0px;
|
||||
|
||||
animation: opacityPulse 2s ease-out;
|
||||
animation-iteration-count: infinite;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.projected-block {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.block-body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes opacityPulse {
|
||||
0% {opacity: 0.7;}
|
||||
50% {opacity: 1.0;}
|
||||
100% {opacity: 0.7;}
|
||||
}
|
||||
|
||||
.time-difference {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#divider {
|
||||
width: 3px;
|
||||
height: 3000px;
|
||||
left: 0;
|
||||
top: -1000px;
|
||||
background-image: url('/assets/divider-new.png');
|
||||
background-repeat: repeat-y;
|
||||
position: absolute;
|
||||
margin-bottom: 120px;
|
||||
}
|
||||
|
||||
#divider > img {
|
||||
position: absolute;
|
||||
left: -100px;
|
||||
top: -28px;
|
||||
}
|
||||
|
||||
.fees {
|
||||
font-size: 10px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.btcblockmiddle {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.breakRow {
|
||||
height: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.yellow-color {
|
||||
color: #ffd800;
|
||||
}
|
||||
|
||||
.transaction-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.blockchain-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.position-container {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(50% - 60px);
|
||||
}
|
||||
|
||||
.block-height {
|
||||
position: absolute;
|
||||
font-size: 12px;
|
||||
bottom: 160px;
|
||||
width: 100%;
|
||||
left: -12px;
|
||||
text-shadow: 0px 32px 3px #111;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
#divider {
|
||||
top: -50px;
|
||||
}
|
||||
.position-container {
|
||||
top: 100px;
|
||||
}
|
||||
.projected-blocks-container {
|
||||
position: absolute;
|
||||
left: -165px;
|
||||
top: -40px;
|
||||
}
|
||||
.block-height {
|
||||
bottom: 125px;
|
||||
left: inherit;
|
||||
text-shadow: inherit;
|
||||
z-index: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.position-container {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bitcoin-block::after {
|
||||
content: '';
|
||||
width: 125px;
|
||||
height: 24px;
|
||||
position:absolute;
|
||||
top: -24px;
|
||||
left: -20px;
|
||||
background-color: #232838;
|
||||
transform:skew(40deg);
|
||||
transform-origin:top;
|
||||
}
|
||||
|
||||
.bitcoin-block::before {
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 125px;
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: -20px;
|
||||
background-color: #191c27;
|
||||
|
||||
transform: skewY(50deg);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.projected-block.bitcoin-block::after {
|
||||
background-color: #403834;
|
||||
}
|
||||
|
||||
.projected-block.bitcoin-block::before {
|
||||
background-color: #2d2825;
|
||||
}
|
||||
}
|
||||
|
||||
.black-background {
|
||||
background-color: #11131f;
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
}
|
272
frontend/src/app/blockchain/blockchain.component.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import { Component, OnInit, OnDestroy, Renderer2, HostListener } from '@angular/core';
|
||||
import { IMempoolDefaultResponse, IBlock, IProjectedBlock, ITransaction } from './interfaces';
|
||||
import { retryWhen, tap } from 'rxjs/operators';
|
||||
import { MemPoolService } from '../services/mem-pool.service';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { BlockModalComponent } from '../block-modal/block-modal.component';
|
||||
import { ProjectedBlockModalComponent } from '../projected-block-modal/projected-block-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blockchain',
|
||||
templateUrl: './blockchain.component.html',
|
||||
styleUrls: ['./blockchain.component.scss']
|
||||
})
|
||||
export class BlockchainComponent implements OnInit, OnDestroy {
|
||||
blocks: IBlock[] = [];
|
||||
projectedBlocks: IProjectedBlock[] = [];
|
||||
subscription: any;
|
||||
socket: any;
|
||||
innerWidth: any;
|
||||
txBubbleStyle: any = {};
|
||||
|
||||
txTrackingLoading = false;
|
||||
txTrackingEnabled = false;
|
||||
txTrackingTx: ITransaction | null = null;
|
||||
txTrackingBlockHeight = 0;
|
||||
txShowTxNotFound = false;
|
||||
txBubbleArrowPosition = 'top';
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
onResize(event: Event) {
|
||||
this.innerWidth = window.innerWidth;
|
||||
this.moveTxBubbleToPosition();
|
||||
}
|
||||
|
||||
constructor(
|
||||
private memPoolService: MemPoolService,
|
||||
private apiService: ApiService,
|
||||
private renderer: Renderer2,
|
||||
private route: ActivatedRoute,
|
||||
private modalService: NgbModal,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
this.txBubbleStyle = {
|
||||
'position': 'absolute',
|
||||
'top': '425px',
|
||||
'visibility': 'hidden',
|
||||
};
|
||||
|
||||
this.innerWidth = window.innerWidth;
|
||||
this.socket = this.apiService.websocketSubject;
|
||||
this.subscription = this.socket
|
||||
.pipe(
|
||||
retryWhen((errors: any) => errors.pipe(
|
||||
tap(() => this.memPoolService.isOffline.next(true))))
|
||||
)
|
||||
.subscribe((response: IMempoolDefaultResponse) => {
|
||||
this.memPoolService.isOffline.next(false);
|
||||
if (response.mempoolInfo && response.txPerSecond !== undefined) {
|
||||
this.memPoolService.loaderSubject.next({
|
||||
memPoolInfo: response.mempoolInfo,
|
||||
txPerSecond: response.txPerSecond,
|
||||
vBytesPerSecond: response.vBytesPerSecond,
|
||||
});
|
||||
}
|
||||
if (response.blocks && response.blocks.length) {
|
||||
this.blocks = response.blocks;
|
||||
this.blocks.reverse();
|
||||
}
|
||||
if (response.block) {
|
||||
if (!this.blocks.some((block) => response.block !== undefined && response.block.height === block.height )) {
|
||||
this.blocks.unshift(response.block);
|
||||
if (this.blocks.length >= 8) {
|
||||
this.blocks.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.conversions) {
|
||||
this.memPoolService.conversions.next(response.conversions);
|
||||
}
|
||||
if (response.projectedBlocks) {
|
||||
this.projectedBlocks = response.projectedBlocks;
|
||||
const mempoolWeight = this.projectedBlocks.map((block) => block.blockWeight).reduce((a, b) => a + b);
|
||||
this.memPoolService.mempoolWeight.next(mempoolWeight);
|
||||
}
|
||||
if (response['track-tx']) {
|
||||
if (response['track-tx'].tracking) {
|
||||
this.txTrackingEnabled = true;
|
||||
this.txTrackingBlockHeight = response['track-tx'].blockHeight;
|
||||
if (response['track-tx'].tx) {
|
||||
this.txTrackingTx = response['track-tx'].tx;
|
||||
this.txTrackingLoading = false;
|
||||
}
|
||||
} else {
|
||||
this.txTrackingEnabled = false;
|
||||
this.txTrackingTx = null;
|
||||
this.txTrackingBlockHeight = 0;
|
||||
}
|
||||
if (response['track-tx'].message && response['track-tx'].message === 'not-found') {
|
||||
this.txTrackingLoading = false;
|
||||
this.txShowTxNotFound = true;
|
||||
setTimeout(() => { this.txShowTxNotFound = false; }, 2000);
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.moveTxBubbleToPosition();
|
||||
});
|
||||
}
|
||||
},
|
||||
(err: Error) => console.log(err)
|
||||
);
|
||||
this.renderer.addClass(document.body, 'disable-scroll');
|
||||
|
||||
this.route.paramMap
|
||||
.subscribe((params: ParamMap) => {
|
||||
const txId: string | null = params.get('id');
|
||||
if (!txId) {
|
||||
return;
|
||||
}
|
||||
this.txTrackingLoading = true;
|
||||
this.socket.next({'action': 'track-tx', 'txId': txId});
|
||||
});
|
||||
|
||||
this.memPoolService.txIdSearch
|
||||
.subscribe((txId) => {
|
||||
if (txId) {
|
||||
this.txTrackingLoading = true;
|
||||
this.socket.next({'action': 'track-tx', 'txId': txId});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
moveTxBubbleToPosition() {
|
||||
let element: HTMLElement | null = null;
|
||||
if (this.txTrackingBlockHeight === 0) {
|
||||
const index = this.projectedBlocks.findIndex((pB) => pB.hasMytx);
|
||||
if (index > -1) {
|
||||
element = document.getElementById('projected-block-' + index);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
element = document.getElementById('bitcoin-block-' + this.txTrackingBlockHeight);
|
||||
}
|
||||
|
||||
this.txBubbleStyle['visibility'] = 'visible';
|
||||
this.txBubbleStyle['position'] = 'absolute';
|
||||
|
||||
if (!element) {
|
||||
if (this.innerWidth <= 768) {
|
||||
this.txBubbleArrowPosition = 'bottom';
|
||||
this.txBubbleStyle['left'] = window.innerWidth / 2 - 50 + 'px';
|
||||
this.txBubbleStyle['bottom'] = '270px';
|
||||
this.txBubbleStyle['top'] = 'inherit';
|
||||
this.txBubbleStyle['position'] = 'fixed';
|
||||
} else {
|
||||
this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px';
|
||||
this.txBubbleArrowPosition = 'right';
|
||||
this.txBubbleStyle['top'] = '425px';
|
||||
}
|
||||
} else {
|
||||
this.txBubbleArrowPosition = 'top';
|
||||
const domRect: DOMRect | ClientRect = element.getBoundingClientRect();
|
||||
this.txBubbleStyle['left'] = domRect.left - 50 + 'px';
|
||||
this.txBubbleStyle['top'] = domRect.top + 125 + window.scrollY + 'px';
|
||||
|
||||
if (domRect.left + 100 > window.innerWidth) {
|
||||
this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px';
|
||||
this.txBubbleArrowPosition = 'right';
|
||||
} else if (domRect.left + 220 > window.innerWidth) {
|
||||
this.txBubbleStyle['left'] = window.innerWidth - 240 + 'px';
|
||||
this.txBubbleArrowPosition = 'top-right';
|
||||
} else {
|
||||
this.txBubbleStyle['left'] = domRect.left + 15 + 'px';
|
||||
}
|
||||
|
||||
if (domRect.left < 86) {
|
||||
this.txBubbleArrowPosition = 'top-left';
|
||||
this.txBubbleStyle['left'] = 125 + 'px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTimeSinceMined(block: IBlock): string {
|
||||
const minutes = ((new Date().getTime()) - (new Date(block.time * 1000).getTime())) / 1000 / 60;
|
||||
if (minutes >= 120) {
|
||||
return Math.floor(minutes / 60) + ' hours';
|
||||
}
|
||||
if (minutes >= 60) {
|
||||
return Math.floor(minutes / 60) + ' hour';
|
||||
}
|
||||
if (minutes <= 1) {
|
||||
return '< 1 minute';
|
||||
}
|
||||
if (minutes === 1) {
|
||||
return '1 minute';
|
||||
}
|
||||
return Math.round(minutes) + ' minutes';
|
||||
}
|
||||
|
||||
getStyleForBlock(block: IBlock) {
|
||||
const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100;
|
||||
if (this.innerWidth <= 768) {
|
||||
return {
|
||||
'top': 155 * this.blocks.indexOf(block) + 'px',
|
||||
'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%,
|
||||
#9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
'left': 155 * this.blocks.indexOf(block) + 'px',
|
||||
'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%,
|
||||
#9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getStyleForProjectedBlockAtIndex(index: number) {
|
||||
const greenBackgroundHeight = 100 - (this.projectedBlocks[index].blockWeight / 4000000) * 100;
|
||||
if (this.innerWidth <= 768) {
|
||||
if (index === 3) {
|
||||
return {
|
||||
'top': 40 + index * 155 + 'px'
|
||||
};
|
||||
}
|
||||
return {
|
||||
'top': 40 + index * 155 + 'px',
|
||||
'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%,
|
||||
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
|
||||
};
|
||||
} else {
|
||||
if (index === 3) {
|
||||
return {
|
||||
'right': 40 + index * 155 + 'px'
|
||||
};
|
||||
}
|
||||
return {
|
||||
'right': 40 + index * 155 + 'px',
|
||||
'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%,
|
||||
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
trackByProjectedFn(index: number) {
|
||||
return index;
|
||||
}
|
||||
|
||||
trackByBlocksFn(index: number, item: IBlock) {
|
||||
return item.height;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
this.renderer.removeClass(document.body, 'disable-scroll');
|
||||
}
|
||||
|
||||
openBlockModal(block: IBlock) {
|
||||
const modalRef = this.modalService.open(BlockModalComponent, { size: 'lg' });
|
||||
modalRef.componentInstance.block = block;
|
||||
}
|
||||
|
||||
openProjectedBlockModal(block: IBlock, index: number) {
|
||||
const modalRef = this.modalService.open(ProjectedBlockModalComponent, { size: 'lg' });
|
||||
modalRef.componentInstance.block = block;
|
||||
modalRef.componentInstance.index = index;
|
||||
}
|
||||
}
|
176
frontend/src/app/blockchain/interfaces.ts
Normal file
@ -0,0 +1,176 @@
|
||||
export interface IMempoolInfo {
|
||||
size: number;
|
||||
bytes: number;
|
||||
usage: number;
|
||||
maxmempool: number;
|
||||
mempoolminfee: number;
|
||||
minrelaytxfee: number;
|
||||
}
|
||||
|
||||
export interface IMempoolDefaultResponse {
|
||||
mempoolInfo?: IMempoolInfo;
|
||||
blocks?: IBlock[];
|
||||
block?: IBlock;
|
||||
projectedBlocks?: IProjectedBlock[];
|
||||
txPerSecond?: number;
|
||||
vBytesPerSecond: number;
|
||||
'track-tx'?: ITrackTx;
|
||||
conversions?: any;
|
||||
}
|
||||
|
||||
export interface ITrackTx {
|
||||
tx?: ITransaction;
|
||||
blockHeight: number;
|
||||
tracking: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface IProjectedBlock {
|
||||
blockSize: number;
|
||||
blockWeight: number;
|
||||
maxFee: number;
|
||||
maxWeightFee: number;
|
||||
medianFee: number;
|
||||
minFee: number;
|
||||
minWeightFee: number;
|
||||
nTx: number;
|
||||
hasMytx: boolean;
|
||||
}
|
||||
|
||||
export interface IStrippedBlock {
|
||||
bits: number;
|
||||
difficulty: number;
|
||||
hash: string;
|
||||
height: number;
|
||||
nTx: number;
|
||||
size: number;
|
||||
strippedsize: number;
|
||||
time: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface ITransaction {
|
||||
txid: string;
|
||||
hash: string;
|
||||
version: number;
|
||||
size: number;
|
||||
vsize: number;
|
||||
locktime: number;
|
||||
vin: Vin[];
|
||||
vout: Vout[];
|
||||
hex: string;
|
||||
|
||||
fee: number;
|
||||
feePerVsize: number;
|
||||
feePerWeightUnit: number;
|
||||
}
|
||||
|
||||
export interface IBlock {
|
||||
hash: string;
|
||||
confirmations: number;
|
||||
strippedsize: number;
|
||||
size: number;
|
||||
weight: number;
|
||||
height: number;
|
||||
version: number;
|
||||
versionHex: string;
|
||||
merkleroot: string;
|
||||
tx: ITransaction[];
|
||||
time: number;
|
||||
mediantime: number;
|
||||
nonce: number;
|
||||
bits: string;
|
||||
difficulty: number;
|
||||
chainwork: string;
|
||||
nTx: number;
|
||||
previousblockhash: string;
|
||||
|
||||
minFee: number;
|
||||
maxFee: number;
|
||||
medianFee: number;
|
||||
fees: number;
|
||||
}
|
||||
|
||||
interface ScriptSig {
|
||||
asm: string;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
interface Vin {
|
||||
txid: string;
|
||||
vout: number;
|
||||
scriptSig: ScriptSig;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
interface ScriptPubKey {
|
||||
asm: string;
|
||||
hex: string;
|
||||
reqSigs: number;
|
||||
type: string;
|
||||
addresses: string[];
|
||||
}
|
||||
|
||||
interface Vout {
|
||||
value: number;
|
||||
n: number;
|
||||
scriptPubKey: ScriptPubKey;
|
||||
}
|
||||
|
||||
export interface IMempoolStats {
|
||||
id: number;
|
||||
added: string;
|
||||
unconfirmed_transactions: number;
|
||||
tx_per_second: number;
|
||||
vbytes_per_second: number;
|
||||
mempool_byte_weight: number;
|
||||
fee_data: IFeeData;
|
||||
vsize_1: number;
|
||||
vsize_2: number;
|
||||
vsize_3: number;
|
||||
vsize_4: number;
|
||||
vsize_5: number;
|
||||
vsize_6: number;
|
||||
vsize_8: number;
|
||||
vsize_10: number;
|
||||
vsize_12: number;
|
||||
vsize_15: number;
|
||||
vsize_20: number;
|
||||
vsize_30: number;
|
||||
vsize_40: number;
|
||||
vsize_50: number;
|
||||
vsize_60: number;
|
||||
vsize_70: number;
|
||||
vsize_80: number;
|
||||
vsize_90: number;
|
||||
vsize_100: number;
|
||||
vsize_125: number;
|
||||
vsize_150: number;
|
||||
vsize_175: number;
|
||||
vsize_200: number;
|
||||
vsize_250: number;
|
||||
vsize_300: number;
|
||||
vsize_350: number;
|
||||
vsize_400: number;
|
||||
vsize_500: number;
|
||||
vsize_600: number;
|
||||
vsize_700: number;
|
||||
vsize_800: number;
|
||||
vsize_900: number;
|
||||
vsize_1000: number;
|
||||
vsize_1200: number;
|
||||
vsize_1400: number;
|
||||
vsize_1600: number;
|
||||
vsize_1800: number;
|
||||
vsize_2000: number;
|
||||
}
|
||||
|
||||
export interface IBlockTransaction {
|
||||
f: number;
|
||||
fpv: number;
|
||||
}
|
||||
|
||||
interface IFeeData {
|
||||
wu: { [ fee: string ]: number };
|
||||
vsize: { [ fee: string ]: number };
|
||||
}
|
18
frontend/src/app/footer/footer.component.html
Normal file
@ -0,0 +1,18 @@
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="my-2 my-md-0 mr-md-3">
|
||||
<div *ngIf="memPoolInfo" class="info-block">
|
||||
<span class="unconfirmedTx">Unconfirmed transactions:</span> <b>{{ memPoolInfo?.memPoolInfo?.size | number }} ({{ mempoolBlocks }} block<span [hidden]="mempoolBlocks <= 1">s</span>)</b>
|
||||
<br />
|
||||
<span class="mempoolSize">Tx per second:</span> <b>{{ memPoolInfo?.txPerSecond | number : '1.2-2' }} tx/s</b>
|
||||
<br />
|
||||
<span class="txPerSecond">Tx weight per second:</span>
|
||||
|
||||
<div class="progress">
|
||||
<div class="progress-bar {{ progressClass }}" role="progressbar" [ngStyle]="{'width': progressWidth}">{{ memPoolInfo?.vBytesPerSecond | ceil | number }} vBytes/s</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
42
frontend/src/app/footer/footer.component.scss
Normal file
@ -0,0 +1,42 @@
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background-color: #1d1f31;
|
||||
}
|
||||
|
||||
.footer > .container {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.txPerSecond {
|
||||
color: #4a9ff4;
|
||||
}
|
||||
|
||||
.mempoolSize {
|
||||
color: #4a68b9;
|
||||
}
|
||||
|
||||
.unconfirmedTx {
|
||||
color: #f14d80;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
float:left;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: inline-flex;
|
||||
width: 150px;
|
||||
background-color: #2d3348;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.bg-warning {
|
||||
background-color: #b58800 !important;
|
||||
}
|
54
frontend/src/app/footer/footer.component.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { MemPoolService, MemPoolState } from '../services/mem-pool.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrls: ['./footer.component.scss']
|
||||
})
|
||||
export class FooterComponent implements OnInit {
|
||||
memPoolInfo: MemPoolState | undefined;
|
||||
mempoolBlocks = 0;
|
||||
progressWidth = '';
|
||||
progressClass: string;
|
||||
|
||||
constructor(
|
||||
private memPoolService: MemPoolService
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.memPoolService.loaderSubject
|
||||
.subscribe((mempoolState) => {
|
||||
this.memPoolInfo = mempoolState;
|
||||
this.updateProgress();
|
||||
});
|
||||
this.memPoolService.mempoolWeight
|
||||
.subscribe((mempoolWeight) => {
|
||||
this.mempoolBlocks = Math.ceil(mempoolWeight / 4000000);
|
||||
});
|
||||
}
|
||||
|
||||
updateProgress() {
|
||||
if (!this.memPoolInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vBytesPerSecondLimit = 1667;
|
||||
|
||||
let vBytesPerSecond = this.memPoolInfo.vBytesPerSecond;
|
||||
if (vBytesPerSecond > 1667) {
|
||||
vBytesPerSecond = 1667;
|
||||
}
|
||||
|
||||
const percent = Math.round((vBytesPerSecond / vBytesPerSecondLimit) * 100);
|
||||
this.progressWidth = percent + '%';
|
||||
|
||||
if (percent <= 75) {
|
||||
this.progressClass = 'bg-success';
|
||||
} else if (percent <= 99) {
|
||||
this.progressClass = 'bg-warning';
|
||||
} else {
|
||||
this.progressClass = 'bg-danger';
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Fee distribution for projected block</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div>
|
||||
<table class="table table-borderless table-sm">
|
||||
<tr>
|
||||
<th>Median fee:</th>
|
||||
<td>~{{ block.medianFee | ceil }} sat/vB <span *ngIf="conversions">(~<span class="green-color">{{ conversions.USD * (block.medianFee/100000000)*250 | currency:'USD':'symbol':'1.2-2' }}</span>/tx)</span></td>
|
||||
<th>Tx count:</th>
|
||||
<td>{{ block.nTx }} transactions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Fee span:</th>
|
||||
<td class="yellow-color">{{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total fees:</th>
|
||||
<td>{{ block.fees | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * block.fees| currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div style="height: 400px;" *ngIf="mempoolVsizeFeesData; else loadingFees">
|
||||
<app-chartist
|
||||
[data]="mempoolVsizeFeesData"
|
||||
[type]="'Bar'"
|
||||
[options]="mempoolVsizeFeesOptions">
|
||||
</app-chartist>
|
||||
</div>
|
||||
<ng-template #loadingFees>
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
@ -0,0 +1,7 @@
|
||||
.yellow-color {
|
||||
color: #ffd800;
|
||||
}
|
||||
|
||||
.green-color {
|
||||
color: #3bcc49;
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
import { Component, OnInit, Input } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { IBlock } from '../blockchain/interfaces';
|
||||
import { MemPoolService } from '../services/mem-pool.service';
|
||||
import * as Chartist from 'chartist';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projected-block-modal',
|
||||
templateUrl: './projected-block-modal.component.html',
|
||||
styleUrls: ['./projected-block-modal.component.scss']
|
||||
})
|
||||
export class ProjectedBlockModalComponent implements OnInit {
|
||||
@Input() block: IBlock;
|
||||
@Input() index: number;
|
||||
|
||||
mempoolVsizeFeesData: any;
|
||||
mempoolVsizeFeesOptions: any;
|
||||
conversions: any;
|
||||
|
||||
constructor(
|
||||
public activeModal: NgbActiveModal,
|
||||
private apiService: ApiService,
|
||||
private memPoolService: MemPoolService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
this.mempoolVsizeFeesOptions = {
|
||||
showArea: false,
|
||||
showLine: false,
|
||||
fullWidth: false,
|
||||
showPoint: false,
|
||||
low: 0,
|
||||
axisX: {
|
||||
position: 'start',
|
||||
showLabel: false,
|
||||
offset: 0,
|
||||
showGrid: false,
|
||||
},
|
||||
axisY: {
|
||||
position: 'end',
|
||||
scaleMinSpace: 40,
|
||||
showGrid: false,
|
||||
},
|
||||
plugins: [
|
||||
Chartist.plugins.tooltip({
|
||||
tooltipOffset: {
|
||||
x: 15,
|
||||
y: 250
|
||||
},
|
||||
transformTooltipTextFnc: (value: number): any => {
|
||||
return Math.ceil(value) + ' sat/vB';
|
||||
},
|
||||
anchorToPoint: false,
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
this.memPoolService.conversions
|
||||
.subscribe((conversions) => {
|
||||
this.conversions = conversions;
|
||||
});
|
||||
|
||||
this.apiService.listTransactionsForProjectedBlock$(this.index)
|
||||
.subscribe((data) => {
|
||||
this.mempoolVsizeFeesData = {
|
||||
labels: data.map((x, i) => i),
|
||||
series: [data.map((tx) => tx.fpv)]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
68
frontend/src/app/services/api.service.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { webSocket } from 'rxjs/webSocket';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { IMempoolDefaultResponse, IMempoolStats, IBlockTransaction } from '../blockchain/interfaces';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
|
||||
let WEB_SOCKET_URL = 'wss://mempool.space:8999';
|
||||
let API_BASE_URL = 'https://mempool.space:8999/api/v1';
|
||||
|
||||
if (!environment.production) {
|
||||
WEB_SOCKET_URL = 'ws://localhost:8999';
|
||||
API_BASE_URL = '/api/v1';
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
constructor(
|
||||
private httpClient: HttpClient,
|
||||
) { }
|
||||
|
||||
websocketSubject = webSocket<IMempoolDefaultResponse>(WEB_SOCKET_URL);
|
||||
|
||||
listTransactionsForBlock$(height: number): Observable<IBlockTransaction[]> {
|
||||
return this.httpClient.get<IBlockTransaction[]>(API_BASE_URL + '/transactions/height/' + height);
|
||||
}
|
||||
|
||||
listTransactionsForProjectedBlock$(index: number): Observable<IBlockTransaction[]> {
|
||||
return this.httpClient.get<IBlockTransaction[]>(API_BASE_URL + '/transactions/projected/' + index);
|
||||
}
|
||||
|
||||
listLiveStatistics$(lastId: number): Observable<IMempoolStats[]> {
|
||||
const params = new HttpParams()
|
||||
.set('lastId', lastId.toString());
|
||||
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/live', {
|
||||
params: params
|
||||
});
|
||||
}
|
||||
|
||||
list2HStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/2h');
|
||||
}
|
||||
|
||||
list24HStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/24h');
|
||||
}
|
||||
|
||||
list1WStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/1w');
|
||||
}
|
||||
|
||||
list1MStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/1m');
|
||||
}
|
||||
|
||||
list3MStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/3m');
|
||||
}
|
||||
|
||||
list6MStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/6m');
|
||||
}
|
||||
|
||||
}
|
20
frontend/src/app/services/mem-pool.service.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Subject, ReplaySubject } from 'rxjs';
|
||||
import { IMempoolInfo } from '../blockchain/interfaces';
|
||||
|
||||
export interface MemPoolState {
|
||||
memPoolInfo: IMempoolInfo;
|
||||
txPerSecond: number;
|
||||
vBytesPerSecond: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MemPoolService {
|
||||
loaderSubject = new Subject<MemPoolState>();
|
||||
isOffline = new Subject<boolean>();
|
||||
txIdSearch = new Subject<string>();
|
||||
conversions = new ReplaySubject<any>();
|
||||
mempoolWeight = new Subject<number>();
|
||||
}
|
63
frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/* tslint:disable */
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { isNumberFinite, isPositive, isInteger, toDecimal } from './utils';
|
||||
|
||||
export type ByteUnit = 'B' | 'kB' | 'MB' | 'GB' | 'TB';
|
||||
|
||||
@Pipe({
|
||||
name: 'bytes'
|
||||
})
|
||||
export class BytesPipe implements PipeTransform {
|
||||
|
||||
static formats: { [key: string]: { max: number, prev?: ByteUnit } } = {
|
||||
'B': {max: 1000},
|
||||
'kB': {max: Math.pow(1000, 2), prev: 'B'},
|
||||
'MB': {max: Math.pow(1000, 3), prev: 'kB'},
|
||||
'GB': {max: Math.pow(1000, 4), prev: 'MB'},
|
||||
'TB': {max: Number.MAX_SAFE_INTEGER, prev: 'GB'}
|
||||
};
|
||||
|
||||
transform(input: any, decimal: number = 0, from: ByteUnit = 'B', to?: ByteUnit): any {
|
||||
|
||||
if (!(isNumberFinite(input) &&
|
||||
isNumberFinite(decimal) &&
|
||||
isInteger(decimal) &&
|
||||
isPositive(decimal))) {
|
||||
return input;
|
||||
}
|
||||
|
||||
let bytes = input;
|
||||
let unit = from;
|
||||
while (unit !== 'B') {
|
||||
bytes *= 1024;
|
||||
unit = BytesPipe.formats[unit].prev!;
|
||||
}
|
||||
|
||||
if (to) {
|
||||
const format = BytesPipe.formats[to];
|
||||
|
||||
const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal);
|
||||
|
||||
return BytesPipe.formatResult(result, to);
|
||||
}
|
||||
|
||||
for (const key in BytesPipe.formats) {
|
||||
const format = BytesPipe.formats[key];
|
||||
if (bytes < format.max) {
|
||||
|
||||
const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal);
|
||||
|
||||
return BytesPipe.formatResult(result, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static formatResult(result: number, unit: string): string {
|
||||
return `${result} ${unit}`;
|
||||
}
|
||||
|
||||
static calculateResult(format: { max: number, prev?: ByteUnit }, bytes: number) {
|
||||
const prev = format.prev ? BytesPipe.formats[format.prev] : undefined;
|
||||
return prev ? bytes / prev.max : bytes;
|
||||
}
|
||||
}
|
311
frontend/src/app/shared/pipes/bytes-pipe/utils.ts
Normal file
@ -0,0 +1,311 @@
|
||||
/* tslint:disable */
|
||||
|
||||
export type CollectionPredicate = (item?: any, index?: number, collection?: any[]) => boolean;
|
||||
|
||||
export function isUndefined(value: any): value is undefined {
|
||||
|
||||
return typeof value === 'undefined';
|
||||
}
|
||||
|
||||
export function isNull(value: any): value is null {
|
||||
return value === null;
|
||||
}
|
||||
|
||||
export function isNumber(value: any): value is number {
|
||||
return typeof value === 'number';
|
||||
}
|
||||
|
||||
export function isNumberFinite(value: any): value is number {
|
||||
return isNumber(value) && isFinite(value);
|
||||
}
|
||||
|
||||
// Not strict positive
|
||||
export function isPositive(value: number): boolean {
|
||||
return value >= 0;
|
||||
}
|
||||
|
||||
|
||||
export function isInteger(value: number): boolean {
|
||||
// No rest, is an integer
|
||||
return (value % 1) === 0;
|
||||
}
|
||||
|
||||
export function isNil(value: any): value is (null | undefined) {
|
||||
return value === null || typeof (value) === 'undefined';
|
||||
}
|
||||
|
||||
export function isString(value: any): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
export function isObject(value: any): boolean {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
export function isArray(value: any): boolean {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isFunction(value: any): boolean {
|
||||
return typeof value === 'function';
|
||||
}
|
||||
|
||||
export function toDecimal(value: number, decimal: number): number {
|
||||
return Math.round(value * Math.pow(10, decimal)) / Math.pow(10, decimal);
|
||||
}
|
||||
|
||||
export function upperFirst(value: string): string {
|
||||
return value.slice(0, 1).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
export function createRound(method: string): Function {
|
||||
// <any>Math to suppress error
|
||||
const func: any = (<any>Math)[method];
|
||||
return function (value: number, precision: number = 0) {
|
||||
if (typeof value === 'string') {
|
||||
throw new TypeError('Rounding method needs a number');
|
||||
}
|
||||
if (typeof precision !== 'number' || isNaN(precision)) {
|
||||
precision = 0;
|
||||
}
|
||||
if (precision) {
|
||||
let pair = `${value}e`.split('e');
|
||||
const val = func(`${pair[0]}e` + (+pair[1] + precision));
|
||||
pair = `${val}e`.split('e');
|
||||
return +(pair[0] + 'e' + (+pair[1] - precision));
|
||||
}
|
||||
return func(value);
|
||||
};
|
||||
}
|
||||
|
||||
export function leftPad(str: string, len: number = 0, ch: any = ' ') {
|
||||
str = String(str);
|
||||
ch = toString(ch);
|
||||
let i = -1;
|
||||
const length = len - str.length;
|
||||
while (++i < length && (str.length + ch.length) <= len) {
|
||||
str = ch + str;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function rightPad(str: string, len: number = 0, ch: any = ' ') {
|
||||
str = String(str);
|
||||
ch = toString(ch);
|
||||
let i = -1;
|
||||
const length = len - str.length;
|
||||
while (++i < length && (str.length + ch.length) <= len) {
|
||||
str += ch;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function toString(value: number | string) {
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
export function pad(str: string, len: number = 0, ch: any = ' '): string {
|
||||
str = String(str);
|
||||
ch = toString(ch);
|
||||
let i = -1;
|
||||
const length = len - str.length;
|
||||
|
||||
let left = true;
|
||||
while (++i < length) {
|
||||
const l = (str.length + ch.length <= len) ? (str.length + ch.length) : (str.length + 1);
|
||||
if (left) {
|
||||
str = leftPad(str, l, ch);
|
||||
} else {
|
||||
str = rightPad(str, l, ch);
|
||||
}
|
||||
left = !left;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function flatten(input: any[], index: number = 0): any[] {
|
||||
|
||||
if (index >= input.length) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (isArray(input[index])) {
|
||||
return flatten(
|
||||
input.slice(0, index).concat(input[index], input.slice(index + 1)),
|
||||
index
|
||||
);
|
||||
}
|
||||
|
||||
return flatten(input, index + 1);
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function getProperty(value: { [key: string]: any }, key: string): any {
|
||||
|
||||
if (isNil(value) || !isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keys: string[] = key.split('.');
|
||||
let result: any = value[keys.shift()!];
|
||||
|
||||
for (const kk of keys) {
|
||||
if (isNil(result) || !isObject(result)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
result = result[kk];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sum(input: Array<number>, initial = 0): number {
|
||||
|
||||
return input.reduce((previous: number, current: number) => previous + current, initial);
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array-in-javascript
|
||||
export function shuffle(input: any): any {
|
||||
|
||||
if (!isArray(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const copy = [...input];
|
||||
|
||||
for (let i = copy.length; i; --i) {
|
||||
const j = Math.floor(Math.random() * i);
|
||||
const x = copy[i - 1];
|
||||
copy[i - 1] = copy[j];
|
||||
copy[j] = x;
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function deepIndexOf(collection: any[], value: any) {
|
||||
|
||||
let index = -1;
|
||||
const length = collection.length;
|
||||
|
||||
while (++index < length) {
|
||||
if (deepEqual(value, collection[index])) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
export function deepEqual(a: any, b: any) {
|
||||
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(typeof a === 'object' && typeof b === 'object')) {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test for A's keys different from B.
|
||||
const hasOwn = Object.prototype.hasOwnProperty;
|
||||
for (let i = 0; i < keysA.length; i++) {
|
||||
const key = keysA[i];
|
||||
if (!hasOwn.call(b, keysA[i]) || !deepEqual(a[key], b[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isDeepObject(object: any) {
|
||||
|
||||
return object.__isDeepObject__;
|
||||
}
|
||||
|
||||
export function wrapDeep(object: any) {
|
||||
|
||||
return new DeepWrapper(object);
|
||||
}
|
||||
|
||||
export function unwrapDeep(object: any) {
|
||||
|
||||
if (isDeepObject(object)) {
|
||||
return object.data;
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
export class DeepWrapper {
|
||||
|
||||
public __isDeepObject__ = true;
|
||||
|
||||
constructor(public data: any) { }
|
||||
}
|
||||
|
||||
export function count(input: any): any {
|
||||
|
||||
if (!isArray(input) && !isObject(input) && !isString(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (isObject(input)) {
|
||||
return Object.keys(input).map((value) => input[value]).length;
|
||||
}
|
||||
|
||||
return input.length;
|
||||
}
|
||||
|
||||
export function empty(input: any): any {
|
||||
|
||||
if (!isArray(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return input.length === 0;
|
||||
}
|
||||
|
||||
export function every(input: any, predicate: CollectionPredicate) {
|
||||
|
||||
if (!isArray(input) || !predicate) {
|
||||
return input;
|
||||
}
|
||||
|
||||
let result = true;
|
||||
let i = -1;
|
||||
|
||||
while (++i < input.length && result) {
|
||||
result = predicate(input[i], i, input);
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function takeUntil(input: any[], predicate: CollectionPredicate) {
|
||||
|
||||
let i = -1;
|
||||
const result: any = [];
|
||||
while (++i < input.length && !predicate(input[i], i, input)) {
|
||||
result[i] = input[i];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function takeWhile(input: any[], predicate: CollectionPredicate) {
|
||||
return takeUntil(input, (item: any, index: number | undefined, collection: any[] | undefined) =>
|
||||
!predicate(item, index, collection));
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({ name: 'ceil' })
|
||||
export class CeilPipe implements PipeTransform {
|
||||
transform(nr: number) {
|
||||
return Math.ceil(nr);
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({ name: 'round' })
|
||||
export class RoundPipe implements PipeTransform {
|
||||
transform(nr: number) {
|
||||
return Math.round(nr);
|
||||
}
|
||||
}
|
34
frontend/src/app/shared/shared.module.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgbButtonsModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
import { BytesPipe } from './pipes/bytes-pipe/bytes.pipe';
|
||||
import { RoundPipe } from './pipes/math-round-pipe/math-round.pipe';
|
||||
import { CeilPipe } from './pipes/math-ceil/math-ceil.pipe';
|
||||
import { ChartistComponent } from '../statistics/chartist.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgbButtonsModule.forRoot(),
|
||||
NgbModalModule.forRoot(),
|
||||
],
|
||||
declarations: [
|
||||
ChartistComponent,
|
||||
RoundPipe,
|
||||
CeilPipe,
|
||||
BytesPipe,
|
||||
],
|
||||
exports: [
|
||||
RoundPipe,
|
||||
CeilPipe,
|
||||
BytesPipe,
|
||||
NgbButtonsModule,
|
||||
NgbModalModule,
|
||||
ChartistComponent,
|
||||
],
|
||||
providers: [
|
||||
BytesPipe
|
||||
]
|
||||
})
|
||||
export class SharedModule { }
|
72
frontend/src/app/statistics/chartist.component.scss
Normal file
@ -0,0 +1,72 @@
|
||||
@import "../../styles.scss";
|
||||
|
||||
.ct-bar-label {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.ct-target-line {
|
||||
stroke: #f5f5f5;
|
||||
stroke-width: 3px;
|
||||
stroke-dasharray: 7px;
|
||||
}
|
||||
|
||||
.ct-area {
|
||||
stroke: none;
|
||||
fill-opacity: 0.9;
|
||||
}
|
||||
|
||||
.ct-label {
|
||||
fill: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.ct-grid {
|
||||
stroke: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* LEGEND */
|
||||
|
||||
.ct-legend {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
left: 0px;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
padding: 0px 0px 0px 30px;
|
||||
top: 90px;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 23px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
li:before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
content: '';
|
||||
border: 3px solid transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
li.inactive:before {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&.ct-legend-inside {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@for $i from 0 to length($ct-series-colors) {
|
||||
.ct-series-#{$i}:before {
|
||||
background-color: nth($ct-series-colors, $i + 1);
|
||||
border-color: nth($ct-series-colors, $i + 1);
|
||||
}
|
||||
}
|
||||
}
|
657
frontend/src/app/statistics/chartist.component.ts
Normal file
@ -0,0 +1,657 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
SimpleChanges,
|
||||
ViewEncapsulation
|
||||
} from '@angular/core';
|
||||
|
||||
import * as Chartist from 'chartist';
|
||||
|
||||
/**
|
||||
* Possible chart types
|
||||
* @type {String}
|
||||
*/
|
||||
export type ChartType = 'Pie' | 'Bar' | 'Line';
|
||||
|
||||
export type ChartInterfaces =
|
||||
| Chartist.IChartistPieChart
|
||||
| Chartist.IChartistBarChart
|
||||
| Chartist.IChartistLineChart;
|
||||
export type ChartOptions =
|
||||
| Chartist.IBarChartOptions
|
||||
| Chartist.ILineChartOptions
|
||||
| Chartist.IPieChartOptions;
|
||||
export type ResponsiveOptionTuple = Chartist.IResponsiveOptionTuple<
|
||||
ChartOptions
|
||||
>;
|
||||
export type ResponsiveOptions = ResponsiveOptionTuple[];
|
||||
|
||||
/**
|
||||
* Represent a chart event.
|
||||
* For possible values, check the Chartist docs.
|
||||
*/
|
||||
export interface ChartEvent {
|
||||
[eventName: string]: (data: any) => void;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chartist',
|
||||
template: '<ng-content></ng-content>',
|
||||
styleUrls: ['./chartist.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class ChartistComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input()
|
||||
// @ts-ignore
|
||||
public data: Promise<Chartist.IChartistData> | Chartist.IChartistData;
|
||||
|
||||
// @ts-ignore
|
||||
@Input() public type: Promise<ChartType> | ChartType;
|
||||
|
||||
@Input()
|
||||
// @ts-ignore
|
||||
public options: Promise<Chartist.IChartOptions> | Chartist.IChartOptions;
|
||||
|
||||
@Input()
|
||||
// @ts-ignore
|
||||
public responsiveOptions: Promise<ResponsiveOptions> | ResponsiveOptions;
|
||||
|
||||
// @ts-ignore
|
||||
@Input() public events: ChartEvent;
|
||||
|
||||
// @ts-ignore
|
||||
public chart: ChartInterfaces;
|
||||
|
||||
private element: HTMLElement;
|
||||
|
||||
constructor(element: ElementRef) {
|
||||
this.element = element.nativeElement;
|
||||
}
|
||||
|
||||
public ngOnInit(): Promise<ChartInterfaces> {
|
||||
if (!this.type || !this.data) {
|
||||
Promise.reject('Expected at least type and data.');
|
||||
}
|
||||
|
||||
return this.renderChart().then((chart) => {
|
||||
if (this.events !== undefined) {
|
||||
this.bindEvents(chart);
|
||||
}
|
||||
|
||||
return chart;
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
this.update(changes);
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
if (this.chart) {
|
||||
this.chart.detach();
|
||||
}
|
||||
}
|
||||
|
||||
public renderChart(): Promise<ChartInterfaces> {
|
||||
const promises: any[] = [
|
||||
this.type,
|
||||
this.element,
|
||||
this.data,
|
||||
this.options,
|
||||
this.responsiveOptions
|
||||
];
|
||||
|
||||
return Promise.all(promises).then((values) => {
|
||||
const [type, ...args]: any = values;
|
||||
|
||||
if (!(type in Chartist)) {
|
||||
throw new Error(`${type} is not a valid chart type`);
|
||||
}
|
||||
|
||||
this.chart = (Chartist as any)[type](...args);
|
||||
|
||||
return this.chart;
|
||||
});
|
||||
}
|
||||
|
||||
public update(changes: SimpleChanges): void {
|
||||
if (!this.chart || 'type' in changes) {
|
||||
this.renderChart();
|
||||
} else {
|
||||
if (changes.data) {
|
||||
this.data = changes.data.currentValue;
|
||||
}
|
||||
|
||||
if (changes.options) {
|
||||
this.options = changes.options.currentValue;
|
||||
}
|
||||
|
||||
(this.chart as any).update(this.data, this.options);
|
||||
}
|
||||
}
|
||||
|
||||
public bindEvents(chart: any): void {
|
||||
for (const event of Object.keys(this.events)) {
|
||||
chart.on(event, this.events[event]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chartist.js plugin to display a "target" or "goal" line across the chart.
|
||||
* Only tested with bar charts. Works for horizontal and vertical bars.
|
||||
*/
|
||||
(function(window, document, Chartist) {
|
||||
'use strict';
|
||||
|
||||
const defaultOptions = {
|
||||
// The class name so you can style the text
|
||||
className: 'ct-target-line',
|
||||
// The axis to draw the line. y == vertical bars, x == horizontal
|
||||
axis: 'y',
|
||||
// What value the target line should be drawn at
|
||||
value: null
|
||||
};
|
||||
|
||||
Chartist.plugins = Chartist.plugins || {};
|
||||
|
||||
Chartist.plugins.ctTargetLine = function (options: any) {
|
||||
options = Chartist.extend({}, defaultOptions, options);
|
||||
return function ctTargetLine (chart: any) {
|
||||
|
||||
chart.on('created', function(context: any) {
|
||||
const projectTarget = {
|
||||
y: function (chartRect: any, bounds: any, value: any) {
|
||||
const targetLineY = chartRect.y1 - (chartRect.height() / bounds.max * value);
|
||||
|
||||
return {
|
||||
x1: chartRect.x1,
|
||||
x2: chartRect.x2,
|
||||
y1: targetLineY,
|
||||
y2: targetLineY
|
||||
};
|
||||
},
|
||||
x: function (chartRect: any, bounds: any, value: any) {
|
||||
const targetLineX = chartRect.x1 + (chartRect.width() / bounds.max * value);
|
||||
|
||||
return {
|
||||
x1: targetLineX,
|
||||
x2: targetLineX,
|
||||
y1: chartRect.y1,
|
||||
y2: chartRect.y2
|
||||
};
|
||||
}
|
||||
};
|
||||
// @ts-ignore
|
||||
const targetLine = projectTarget[options.axis](context.chartRect, context.bounds, options.value);
|
||||
|
||||
context.svg.elem('line', targetLine, options.className);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
}(window, document, Chartist));
|
||||
|
||||
|
||||
/**
|
||||
* Chartist.js plugin to display a data label on top of the points in a line chart.
|
||||
*
|
||||
*/
|
||||
/* global Chartist */
|
||||
(function(window, document, Chartist) {
|
||||
'use strict';
|
||||
|
||||
const defaultOptions = {
|
||||
labelClass: 'ct-label',
|
||||
labelOffset: {
|
||||
x: 0,
|
||||
y: -10
|
||||
},
|
||||
textAnchor: 'middle',
|
||||
align: 'center',
|
||||
labelInterpolationFnc: Chartist.noop
|
||||
};
|
||||
|
||||
const labelPositionCalculation = {
|
||||
point: function(data: any) {
|
||||
return {
|
||||
x: data.x,
|
||||
y: data.y
|
||||
};
|
||||
},
|
||||
bar: {
|
||||
left: function(data: any) {
|
||||
return {
|
||||
x: data.x1,
|
||||
y: data.y1
|
||||
};
|
||||
},
|
||||
center: function(data: any) {
|
||||
return {
|
||||
x: data.x1 + (data.x2 - data.x1) / 2,
|
||||
y: data.y1
|
||||
};
|
||||
},
|
||||
right: function(data: any) {
|
||||
return {
|
||||
x: data.x2,
|
||||
y: data.y1
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Chartist.plugins = Chartist.plugins || {};
|
||||
Chartist.plugins.ctPointLabels = function(options: any) {
|
||||
|
||||
options = Chartist.extend({}, defaultOptions, options);
|
||||
|
||||
function addLabel(position: any, data: any) {
|
||||
// if x and y exist concat them otherwise output only the existing value
|
||||
const value = data.value.x !== undefined && data.value.y ?
|
||||
(data.value.x + ', ' + data.value.y) :
|
||||
data.value.y || data.value.x;
|
||||
|
||||
data.group.elem('text', {
|
||||
x: position.x + options.labelOffset.x,
|
||||
y: position.y + options.labelOffset.y,
|
||||
style: 'text-anchor: ' + options.textAnchor
|
||||
}, options.labelClass).text(options.labelInterpolationFnc(value));
|
||||
}
|
||||
|
||||
return function ctPointLabels(chart: any) {
|
||||
if (chart instanceof Chartist.Line || chart instanceof Chartist.Bar) {
|
||||
chart.on('draw', function(data: any) {
|
||||
// @ts-ignore
|
||||
const positonCalculator = labelPositionCalculation[data.type]
|
||||
// @ts-ignore
|
||||
&& labelPositionCalculation[data.type][options.align] || labelPositionCalculation[data.type];
|
||||
if (positonCalculator) {
|
||||
addLabel(positonCalculator(data), data);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
}(window, document, Chartist));
|
||||
|
||||
const defaultOptions = {
|
||||
className: '',
|
||||
classNames: false,
|
||||
removeAll: false,
|
||||
legendNames: false,
|
||||
clickable: true,
|
||||
onClick: null,
|
||||
position: 'top'
|
||||
};
|
||||
|
||||
Chartist.plugins.legend = function (options: any) {
|
||||
let cachedDOMPosition;
|
||||
// Catch invalid options
|
||||
if (options && options.position) {
|
||||
if (!(options.position === 'top' || options.position === 'bottom' || options.position instanceof HTMLElement)) {
|
||||
throw Error('The position you entered is not a valid position');
|
||||
}
|
||||
if (options.position instanceof HTMLElement) {
|
||||
// Detatch DOM element from options object, because Chartist.extend
|
||||
// currently chokes on circular references present in HTMLElements
|
||||
cachedDOMPosition = options.position;
|
||||
delete options.position;
|
||||
}
|
||||
}
|
||||
|
||||
options = Chartist.extend({}, defaultOptions, options);
|
||||
|
||||
if (cachedDOMPosition) {
|
||||
// Reattatch the DOM Element position if it was removed before
|
||||
options.position = cachedDOMPosition;
|
||||
}
|
||||
|
||||
return function legend(chart: any) {
|
||||
|
||||
function removeLegendElement() {
|
||||
const legendElement = chart.container.querySelector('.ct-legend');
|
||||
if (legendElement) {
|
||||
legendElement.parentNode.removeChild(legendElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Set a unique className for each series so that when a series is removed,
|
||||
// the other series still have the same color.
|
||||
function setSeriesClassNames() {
|
||||
chart.data.series = chart.data.series.map(function (series: any, seriesIndex: any) {
|
||||
if (typeof series !== 'object') {
|
||||
series = {
|
||||
value: series
|
||||
};
|
||||
}
|
||||
series.className = series.className || chart.options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex);
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
function createLegendElement() {
|
||||
const legendElement = document.createElement('ul');
|
||||
legendElement.className = 'ct-legend';
|
||||
if (chart instanceof Chartist.Pie) {
|
||||
legendElement.classList.add('ct-legend-inside');
|
||||
}
|
||||
if (typeof options.className === 'string' && options.className.length > 0) {
|
||||
legendElement.classList.add(options.className);
|
||||
}
|
||||
if (chart.options.width) {
|
||||
legendElement.style.cssText = 'width: ' + chart.options.width + 'px;margin: 0 auto;';
|
||||
}
|
||||
return legendElement;
|
||||
}
|
||||
|
||||
// Get the right array to use for generating the legend.
|
||||
function getLegendNames(useLabels: any) {
|
||||
return options.legendNames || (useLabels ? chart.data.labels : chart.data.series);
|
||||
}
|
||||
|
||||
// Initialize the array that associates series with legends.
|
||||
// -1 indicates that there is no legend associated with it.
|
||||
function initSeriesMetadata(useLabels: any) {
|
||||
const seriesMetadata = new Array(chart.data.series.length);
|
||||
for (let i = 0; i < chart.data.series.length; i++) {
|
||||
seriesMetadata[i] = {
|
||||
data: chart.data.series[i],
|
||||
label: useLabels ? chart.data.labels[i] : null,
|
||||
legend: -1
|
||||
};
|
||||
}
|
||||
return seriesMetadata;
|
||||
}
|
||||
|
||||
function createNameElement(i: any, legendText: any, classNamesViable: any) {
|
||||
const li = document.createElement('li');
|
||||
li.classList.add('ct-series-' + i);
|
||||
// Append specific class to a legend element, if viable classes are given
|
||||
if (classNamesViable) {
|
||||
li.classList.add(options.classNames[i]);
|
||||
}
|
||||
li.setAttribute('data-legend', i);
|
||||
li.textContent = legendText;
|
||||
return li;
|
||||
}
|
||||
|
||||
// Append the legend element to the DOM
|
||||
function appendLegendToDOM(legendElement: any) {
|
||||
if (!(options.position instanceof HTMLElement)) {
|
||||
switch (options.position) {
|
||||
case 'top':
|
||||
chart.container.insertBefore(legendElement, chart.container.childNodes[0]);
|
||||
break;
|
||||
|
||||
case 'bottom':
|
||||
chart.container.insertBefore(legendElement, null);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Appends the legend element as the last child of a given HTMLElement
|
||||
options.position.insertBefore(legendElement, null);
|
||||
}
|
||||
}
|
||||
|
||||
function addClickHandler(legendElement: any, legends: any, seriesMetadata: any, useLabels: any) {
|
||||
legendElement.addEventListener('click', function(e: any) {
|
||||
const li = e.target;
|
||||
if (li.parentNode !== legendElement || !li.hasAttribute('data-legend'))
|
||||
return;
|
||||
e.preventDefault();
|
||||
|
||||
const legendIndex = parseInt(li.getAttribute('data-legend'));
|
||||
const legend = legends[legendIndex];
|
||||
|
||||
if (!legend.active) {
|
||||
legend.active = true;
|
||||
li.classList.remove('inactive');
|
||||
} else {
|
||||
legend.active = false;
|
||||
li.classList.add('inactive');
|
||||
|
||||
const activeCount = legends.filter(function(legend: any) { return legend.active; }).length;
|
||||
if (!options.removeAll && activeCount == 0) {
|
||||
// If we can't disable all series at the same time, let's
|
||||
// reenable all of them:
|
||||
for (let i = 0; i < legends.length; i++) {
|
||||
legends[i].active = true;
|
||||
legendElement.childNodes[i].classList.remove('inactive');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newSeries = [];
|
||||
const newLabels = [];
|
||||
|
||||
for (let i = 0; i < seriesMetadata.length; i++) {
|
||||
if (seriesMetadata[i].legend !== -1 && legends[seriesMetadata[i].legend].active) {
|
||||
newSeries.push(seriesMetadata[i].data);
|
||||
newLabels.push(seriesMetadata[i].label);
|
||||
}
|
||||
}
|
||||
|
||||
chart.data.series = newSeries;
|
||||
if (useLabels) {
|
||||
chart.data.labels = newLabels;
|
||||
}
|
||||
|
||||
chart.update();
|
||||
|
||||
if (options.onClick) {
|
||||
options.onClick(chart, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeLegendElement();
|
||||
|
||||
const legendElement = createLegendElement();
|
||||
const useLabels = chart instanceof Chartist.Pie && chart.data.labels && chart.data.labels.length;
|
||||
const legendNames = getLegendNames(useLabels);
|
||||
const seriesMetadata = initSeriesMetadata(useLabels);
|
||||
const legends: any = [];
|
||||
|
||||
// Check if given class names are viable to append to legends
|
||||
const classNamesViable = Array.isArray(options.classNames) && options.classNames.length === legendNames.length;
|
||||
|
||||
// Loop through all legends to set each name in a list item.
|
||||
legendNames.forEach(function (legend: any, i: any) {
|
||||
const legendText = legend.name || legend;
|
||||
const legendSeries = legend.series || [i];
|
||||
|
||||
const li = createNameElement(i, legendText, classNamesViable);
|
||||
legendElement.appendChild(li);
|
||||
|
||||
legendSeries.forEach(function(seriesIndex: any) {
|
||||
seriesMetadata[seriesIndex].legend = i;
|
||||
});
|
||||
|
||||
legends.push({
|
||||
text: legendText,
|
||||
series: legendSeries,
|
||||
active: true
|
||||
});
|
||||
});
|
||||
|
||||
chart.on('created', function (data: any) {
|
||||
appendLegendToDOM(legendElement);
|
||||
});
|
||||
|
||||
if (options.clickable) {
|
||||
setSeriesClassNames();
|
||||
addClickHandler(legendElement, legends, seriesMetadata, useLabels);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Chartist.plugins.tooltip = function (options: any) {
|
||||
options = Chartist.extend({}, defaultOptions, options);
|
||||
|
||||
return function tooltip(chart: any) {
|
||||
let tooltipSelector = options.pointClass;
|
||||
if (chart.constructor.name === Chartist.Bar.prototype.constructor.name) {
|
||||
tooltipSelector = 'ct-bar';
|
||||
} else if (chart.constructor.name === Chartist.Pie.prototype.constructor.name) {
|
||||
// Added support for donut graph
|
||||
if (chart.options.donut) {
|
||||
tooltipSelector = 'ct-slice-donut';
|
||||
} else {
|
||||
tooltipSelector = 'ct-slice-pie';
|
||||
}
|
||||
}
|
||||
|
||||
const $chart = chart.container;
|
||||
let $toolTip = $chart.querySelector('.chartist-tooltip');
|
||||
if (!$toolTip) {
|
||||
$toolTip = document.createElement('div');
|
||||
$toolTip.className = (!options.class) ? 'chartist-tooltip' : 'chartist-tooltip ' + options.class;
|
||||
if (!options.appendToBody) {
|
||||
$chart.appendChild($toolTip);
|
||||
} else {
|
||||
document.body.appendChild($toolTip);
|
||||
}
|
||||
}
|
||||
let height = $toolTip.offsetHeight;
|
||||
let width = $toolTip.offsetWidth;
|
||||
|
||||
hide($toolTip);
|
||||
|
||||
function on(event: any, selector: any, callback: any) {
|
||||
$chart.addEventListener(event, function (e: any) {
|
||||
if (!selector || hasClass(e.target, selector)) {
|
||||
callback(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
on('mouseover', tooltipSelector, function (event: any) {
|
||||
const $point = event.target;
|
||||
let tooltipText = '';
|
||||
|
||||
const isPieChart = (chart instanceof Chartist.Pie) ? $point : $point.parentNode;
|
||||
const seriesName = (isPieChart) ? $point.parentNode.getAttribute('ct:meta') || $point.parentNode.getAttribute('ct:series-name') : '';
|
||||
let meta = $point.getAttribute('ct:meta') || seriesName || '';
|
||||
const hasMeta = !!meta;
|
||||
let value = $point.getAttribute('ct:value');
|
||||
|
||||
if (options.transformTooltipTextFnc && typeof options.transformTooltipTextFnc === 'function') {
|
||||
value = options.transformTooltipTextFnc(value);
|
||||
}
|
||||
|
||||
if (options.tooltipFnc && typeof options.tooltipFnc === 'function') {
|
||||
tooltipText = options.tooltipFnc(meta, value);
|
||||
} else {
|
||||
if (options.metaIsHTML) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = meta;
|
||||
meta = txt.value;
|
||||
}
|
||||
|
||||
meta = '<span class="chartist-tooltip-meta">' + meta + '</span>';
|
||||
|
||||
if (hasMeta) {
|
||||
tooltipText += meta + '<br>';
|
||||
} else {
|
||||
// For Pie Charts also take the labels into account
|
||||
// Could add support for more charts here as well!
|
||||
if (chart instanceof Chartist.Pie) {
|
||||
const label = next($point, 'ct-label');
|
||||
if (label) {
|
||||
tooltipText += text(label) + '<br>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value) {
|
||||
if (options.currency) {
|
||||
if (options.currencyFormatCallback != undefined) {
|
||||
value = options.currencyFormatCallback(value, options);
|
||||
} else {
|
||||
value = options.currency + value.replace(/(\d)(?=(\d{3})+(?:\.\d+)?$)/g, '$1,');
|
||||
}
|
||||
}
|
||||
value = '<span class="chartist-tooltip-value">' + value + '</span>';
|
||||
tooltipText += value;
|
||||
}
|
||||
}
|
||||
|
||||
if (tooltipText) {
|
||||
$toolTip.innerHTML = tooltipText;
|
||||
setPosition(event);
|
||||
show($toolTip);
|
||||
|
||||
// Remember height and width to avoid wrong position in IE
|
||||
height = $toolTip.offsetHeight;
|
||||
width = $toolTip.offsetWidth;
|
||||
}
|
||||
});
|
||||
|
||||
on('mouseout', tooltipSelector, function () {
|
||||
hide($toolTip);
|
||||
});
|
||||
|
||||
on('mousemove', null, function (event: any) {
|
||||
if (false === options.anchorToPoint) {
|
||||
setPosition(event);
|
||||
}
|
||||
});
|
||||
|
||||
function setPosition(event: any) {
|
||||
height = height || $toolTip.offsetHeight;
|
||||
width = width || $toolTip.offsetWidth;
|
||||
const offsetX = - width / 2 + options.tooltipOffset.x
|
||||
const offsetY = - height + options.tooltipOffset.y;
|
||||
let anchorX, anchorY;
|
||||
|
||||
if (!options.appendToBody) {
|
||||
const box = $chart.getBoundingClientRect();
|
||||
const left = event.pageX - box.left - window.pageXOffset ;
|
||||
const top = event.pageY - box.top - window.pageYOffset ;
|
||||
|
||||
if (true === options.anchorToPoint && event.target.x2 && event.target.y2) {
|
||||
anchorX = parseInt(event.target.x2.baseVal.value);
|
||||
anchorY = parseInt(event.target.y2.baseVal.value);
|
||||
}
|
||||
|
||||
$toolTip.style.top = (anchorY || top) + offsetY + 'px';
|
||||
$toolTip.style.left = (anchorX || left) + offsetX + 'px';
|
||||
} else {
|
||||
$toolTip.style.top = event.pageY + offsetY + 'px';
|
||||
$toolTip.style.left = event.pageX + offsetX + 'px';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function show(element: any) {
|
||||
if (!hasClass(element, 'tooltip-show')) {
|
||||
element.className = element.className + ' tooltip-show';
|
||||
}
|
||||
}
|
||||
|
||||
function hide(element: any) {
|
||||
const regex = new RegExp('tooltip-show' + '\\s*', 'gi');
|
||||
element.className = element.className.replace(regex, '').trim();
|
||||
}
|
||||
|
||||
function hasClass(element: any, className: any) {
|
||||
return (' ' + element.getAttribute('class') + ' ').indexOf(' ' + className + ' ') > -1;
|
||||
}
|
||||
|
||||
function next(element: any, className: any) {
|
||||
do {
|
||||
element = element.nextSibling;
|
||||
} while (element && !hasClass(element, className));
|
||||
return element;
|
||||
}
|
||||
|
||||
function text(element: any) {
|
||||
return element.innerText || element.textContent;
|
||||
}
|
108
frontend/src/app/statistics/statistics.component.html
Normal file
@ -0,0 +1,108 @@
|
||||
<div class="container" style="max-width: 100%;">
|
||||
<!--
|
||||
<ul class="nav nav-pills" id="myTab" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLinkActive="active" routerLink="mempool" role="tab">Mempool</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLinkActive="active" routerLink="blocks" role="tab">Blocks</a>
|
||||
</li>
|
||||
</ul>
|
||||
<br/>
|
||||
|
||||
-->
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12" *ngIf="loading">
|
||||
<div class="text-center">
|
||||
<h3>Loading graphs...</h3>
|
||||
<br>
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12">
|
||||
|
||||
<div class="card mb-3" *ngIf="mempoolVsizeFeesData">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-area-chart"></i> Mempool by vbytes (satoshis/vbyte)
|
||||
|
||||
<form [formGroup]="radioGroupForm" style="float: right;">
|
||||
<div class="spinner-border text-light bootstrap-spinner" *ngIf="spinnerLoading"></div>
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs']" fragment="2h"> 2H (LIVE)
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'24h'" [routerLink]="['/graphs']" fragment="24h"> 24H
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'1w'" [routerLink]="['/graphs']" fragment="1w"> 1W
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'1m'" [routerLink]="['/graphs']" fragment="1m"> 1M
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'3m'" [routerLink]="['/graphs']" fragment="3m"> 3M
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'6m'" [routerLink]="['/graphs']" fragment="6m"> 6M
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'1y'" disabled> 1Y
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'all'" disabled> All
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div style="height: 600px;">
|
||||
<app-chartist
|
||||
[data]="mempoolVsizeFeesData"
|
||||
[type]="'Line'"
|
||||
[options]="mempoolVsizeFeesOptions">
|
||||
</app-chartist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12">
|
||||
<div class="card mb-3" *ngIf="mempoolTransactionsWeightPerSecondData">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-area-chart"></i> Transactions weight per second (vBytes/s)</div>
|
||||
<div class="card-body">
|
||||
<div style="height: 600px;">
|
||||
<app-chartist
|
||||
[data]="mempoolTransactionsWeightPerSecondData"
|
||||
[type]="'Line'"
|
||||
[options]="transactionsWeightPerSecondOptions">
|
||||
</app-chartist>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12">
|
||||
<div class="card mb-3" *ngIf="mempoolTransactionsPerSecondData">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-area-chart"></i> Transactions per second (tx/s)</div>
|
||||
<div class="card-body">
|
||||
<div style="height: 600px;">
|
||||
<app-chartist
|
||||
[data]="mempoolTransactionsPerSecondData"
|
||||
[type]="'Line'"
|
||||
[options]="transactionsPerSecondOptions">
|
||||
</app-chartist>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
16
frontend/src/app/statistics/statistics.component.scss
Normal file
@ -0,0 +1,16 @@
|
||||
.card-header {
|
||||
border-bottom: 0;
|
||||
background-color: none;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.bootstrap-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-right: 10px;
|
||||
}
|
274
frontend/src/app/statistics/statistics.component.ts
Normal file
@ -0,0 +1,274 @@
|
||||
import { Component, OnInit, LOCALE_ID, Inject } from '@angular/core';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { formatDate } from '@angular/common';
|
||||
import { BytesPipe } from '../shared/pipes/bytes-pipe/bytes.pipe';
|
||||
|
||||
import * as Chartist from 'chartist';
|
||||
import { FormGroup, FormBuilder } from '@angular/forms';
|
||||
import { IMempoolStats } from '../blockchain/interfaces';
|
||||
import { Subject, of, merge} from 'rxjs';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-statistics',
|
||||
templateUrl: './statistics.component.html',
|
||||
styleUrls: ['./statistics.component.scss']
|
||||
})
|
||||
export class StatisticsComponent implements OnInit {
|
||||
loading = true;
|
||||
spinnerLoading = false;
|
||||
|
||||
mempoolStats: IMempoolStats[] = [];
|
||||
|
||||
mempoolVsizeFeesData: any;
|
||||
mempoolUnconfirmedTransactionsData: any;
|
||||
mempoolTransactionsPerSecondData: any;
|
||||
mempoolTransactionsWeightPerSecondData: any;
|
||||
|
||||
mempoolVsizeFeesOptions: any;
|
||||
transactionsPerSecondOptions: any;
|
||||
transactionsWeightPerSecondOptions: any;
|
||||
|
||||
radioGroupForm: FormGroup;
|
||||
|
||||
reloadData$: Subject<any> = new Subject();
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
@Inject(LOCALE_ID) private locale: string,
|
||||
private bytesPipe: BytesPipe,
|
||||
private formBuilder: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
) {
|
||||
this.radioGroupForm = this.formBuilder.group({
|
||||
'dateSpan': '2h'
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const now = new Date();
|
||||
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
|
||||
Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0);
|
||||
const difference = nextInterval.getTime() - now.getTime();
|
||||
|
||||
setTimeout(() => {
|
||||
setInterval(() => {
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '2h') {
|
||||
this.reloadData$.next();
|
||||
}
|
||||
}, 60 * 1000);
|
||||
}, difference + 1000); // Next whole minute + 1 second
|
||||
|
||||
const labelInterpolationFnc = (value: any, index: any) => {
|
||||
const nr = 6;
|
||||
|
||||
switch (this.radioGroupForm.controls['dateSpan'].value) {
|
||||
case '2h':
|
||||
case '24h':
|
||||
value = formatDate(value, 'HH:mm', this.locale);
|
||||
break;
|
||||
case '1w':
|
||||
value = formatDate(value, 'dd/MM HH:mm', this.locale);
|
||||
break;
|
||||
case '1m':
|
||||
case '3m':
|
||||
case '6m':
|
||||
value = formatDate(value, 'dd/MM', this.locale);
|
||||
}
|
||||
|
||||
return index % nr === 0 ? value : null;
|
||||
};
|
||||
|
||||
this.mempoolVsizeFeesOptions = {
|
||||
showArea: true,
|
||||
showLine: false,
|
||||
fullWidth: true,
|
||||
showPoint: false,
|
||||
low: 0,
|
||||
axisX: {
|
||||
labelInterpolationFnc: labelInterpolationFnc,
|
||||
offset: 40
|
||||
},
|
||||
axisY: {
|
||||
labelInterpolationFnc: (value: number): any => {
|
||||
return this.bytesPipe.transform(value);
|
||||
},
|
||||
offset: 160
|
||||
},
|
||||
plugins: [
|
||||
Chartist.plugins.ctTargetLine({
|
||||
value: 1000000
|
||||
}),
|
||||
Chartist.plugins.legend({
|
||||
legendNames: [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
|
||||
250, 300, 350, 400, 500, 600].map((sats, i, arr) => {
|
||||
if (sats === 600) {
|
||||
return '500+';
|
||||
}
|
||||
if (i === 0) {
|
||||
return '1 sat/vbyte';
|
||||
}
|
||||
return arr[i - 1] + ' - ' + sats;
|
||||
})
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
this.transactionsWeightPerSecondOptions = {
|
||||
showArea: false,
|
||||
showLine: true,
|
||||
showPoint: false,
|
||||
low: 0,
|
||||
axisY: {
|
||||
offset: 40
|
||||
},
|
||||
axisX: {
|
||||
labelInterpolationFnc: labelInterpolationFnc
|
||||
},
|
||||
plugins: [
|
||||
Chartist.plugins.ctTargetLine({
|
||||
value: 1667
|
||||
}),
|
||||
]
|
||||
};
|
||||
|
||||
this.transactionsPerSecondOptions = {
|
||||
showArea: false,
|
||||
showLine: true,
|
||||
showPoint: false,
|
||||
low: 0,
|
||||
axisY: {
|
||||
offset: 40
|
||||
},
|
||||
axisX: {
|
||||
labelInterpolationFnc: labelInterpolationFnc
|
||||
},
|
||||
};
|
||||
|
||||
this.route
|
||||
.fragment
|
||||
.subscribe((fragment) => {
|
||||
if (['2h', '24h', '1w', '1m', '3m', '6m'].indexOf(fragment) > -1) {
|
||||
this.radioGroupForm.controls['dateSpan'].setValue(fragment);
|
||||
}
|
||||
});
|
||||
|
||||
merge(
|
||||
of(''),
|
||||
this.reloadData$,
|
||||
this.radioGroupForm.controls['dateSpan'].valueChanges
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.mempoolStats = [];
|
||||
})
|
||||
)
|
||||
)
|
||||
.pipe(
|
||||
switchMap(() => {
|
||||
this.spinnerLoading = true;
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '6m') {
|
||||
return this.apiService.list6MStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '3m') {
|
||||
return this.apiService.list3MStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '1m') {
|
||||
return this.apiService.list1MStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '1w') {
|
||||
return this.apiService.list1WStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '24h') {
|
||||
return this.apiService.list24HStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '2h' && !this.mempoolStats.length) {
|
||||
return this.apiService.list2HStatistics$();
|
||||
}
|
||||
const lastId = this.mempoolStats[0].id;
|
||||
return this.apiService.listLiveStatistics$(lastId);
|
||||
})
|
||||
)
|
||||
.subscribe((mempoolStats) => {
|
||||
let hasChange = false;
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '2h' && this.mempoolStats.length) {
|
||||
if (mempoolStats.length) {
|
||||
this.mempoolStats = mempoolStats.concat(this.mempoolStats);
|
||||
this.mempoolStats = this.mempoolStats.slice(0, this.mempoolStats.length - mempoolStats.length);
|
||||
hasChange = true;
|
||||
}
|
||||
} else {
|
||||
this.mempoolStats = mempoolStats;
|
||||
hasChange = true;
|
||||
}
|
||||
if (hasChange) {
|
||||
this.handleNewMempoolData(this.mempoolStats.concat([]));
|
||||
}
|
||||
this.loading = false;
|
||||
this.spinnerLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
handleNewMempoolData(mempoolStats: IMempoolStats[]) {
|
||||
mempoolStats.reverse();
|
||||
const labels = mempoolStats.map(stats => stats.added);
|
||||
|
||||
/** Active admins summed up */
|
||||
|
||||
this.mempoolTransactionsPerSecondData = {
|
||||
labels: labels,
|
||||
series: [mempoolStats.map((stats) => stats.tx_per_second)],
|
||||
};
|
||||
|
||||
this.mempoolTransactionsWeightPerSecondData = {
|
||||
labels: labels,
|
||||
series: [mempoolStats.map((stats) => stats.vbytes_per_second)],
|
||||
};
|
||||
|
||||
const finalArrayVbyte = this.generateArray(mempoolStats);
|
||||
|
||||
// Remove the 0-1 fee vbyte since it's practially empty
|
||||
finalArrayVbyte.shift();
|
||||
|
||||
this.mempoolVsizeFeesData = {
|
||||
labels: labels,
|
||||
series: finalArrayVbyte
|
||||
};
|
||||
}
|
||||
|
||||
getTimeToNextTenMinutes(): number {
|
||||
const now = new Date();
|
||||
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
|
||||
Math.floor(now.getMinutes() / 10) * 10 + 10, 0, 0);
|
||||
return nextInterval.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
generateArray(mempoolStats: IMempoolStats[]) {
|
||||
const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
|
||||
250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000];
|
||||
|
||||
logFees.reverse();
|
||||
|
||||
const finalArray: number[][] = [];
|
||||
let feesArray: number[] = [];
|
||||
|
||||
logFees.forEach((fee) => {
|
||||
feesArray = [];
|
||||
mempoolStats.forEach((stats) => {
|
||||
// @ts-ignore
|
||||
const theFee = stats['vsize_' + fee];
|
||||
if (theFee) {
|
||||
feesArray.push(parseInt(theFee, 10));
|
||||
} else {
|
||||
feesArray.push(0);
|
||||
}
|
||||
});
|
||||
if (finalArray.length) {
|
||||
feesArray = feesArray.map((value, i) => value + finalArray[finalArray.length - 1][i]);
|
||||
}
|
||||
finalArray.push(feesArray);
|
||||
});
|
||||
finalArray.reverse();
|
||||
return finalArray;
|
||||
}
|
||||
}
|
26
frontend/src/app/tx-bubble/tx-bubble.component.html
Normal file
@ -0,0 +1,26 @@
|
||||
<div class="txBubble" *ngIf="tx">
|
||||
<span class="txBubbleText" ngClass="arrow-{{ arrowPosition }}">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td class="text-left"><b>Transaction hash</b></td>
|
||||
<td class="text-right"><a href="https://www.blockstream.info/tx/{{ tx?.txid }}" target="_blank">{{ txIdShort }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-left"><b>Fees:</b></td>
|
||||
<td class="text-right">{{ tx?.fee }} BTC</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-left"><b>Fee per vByte:</b></td>
|
||||
<td class="text-right">{{ tx?.feePerVsize | number : '1.2-2' }} sat/vB</td>
|
||||
</tr>
|
||||
</table>
|
||||
<br />
|
||||
|
||||
<span *ngIf="txTrackingBlockHeight === 0">
|
||||
<button type="button" class="btn btn-danger">Unconfirmed</button>
|
||||
</span>
|
||||
<span *ngIf="txTrackingBlockHeight > 0">
|
||||
<button type="button" class="btn btn-success">{{ confirmations }} confirmation<span *ngIf="confirmations > 1">s</span></button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
65
frontend/src/app/tx-bubble/tx-bubble.component.scss
Normal file
@ -0,0 +1,65 @@
|
||||
.txBubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-bottom: 1px dotted #000000;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.txBubble .txBubbleText {
|
||||
width: 300px;
|
||||
background-color: #ffffff;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 0;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 150%;
|
||||
left: 50%;
|
||||
margin-left: -100px;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.txBubble .txBubbleText::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
border-width: 10px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent white transparent;
|
||||
}
|
||||
|
||||
.txBubble .arrow-right.txBubbleText::after {
|
||||
top: calc(50% - 10px);
|
||||
border-color: transparent transparent transparent white;
|
||||
right: -20px;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.txBubble .arrow-left.txBubbleText::after {
|
||||
top: calc(50% - 10px);
|
||||
left: 0;
|
||||
margin-left: -20px;
|
||||
border-width: 10px;
|
||||
border-color: transparent white transparent transparent;
|
||||
}
|
||||
|
||||
.txBubble .arrow-bottom.txBubbleText::after {
|
||||
bottom: -20px;
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
border-width: 10px;
|
||||
border-style: solid;
|
||||
border-color: white transparent transparent transparent;
|
||||
}
|
||||
|
||||
.txBubble .arrow-top-right.txBubbleText::after {
|
||||
left: 80%;
|
||||
}
|
||||
|
||||
.txBubble .arrow-top-left.txBubbleText::after {
|
||||
left: 20%;
|
||||
}
|
26
frontend/src/app/tx-bubble/tx-bubble.component.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Component, OnInit, Input, OnChanges } from '@angular/core';
|
||||
import { ITransaction } from '../blockchain/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tx-bubble',
|
||||
templateUrl: './tx-bubble.component.html',
|
||||
styleUrls: ['./tx-bubble.component.scss']
|
||||
})
|
||||
export class TxBubbleComponent implements OnChanges {
|
||||
@Input() tx: ITransaction | null = null;
|
||||
@Input() txTrackingBlockHeight = 0;
|
||||
@Input() latestBlockHeight = 0;
|
||||
@Input() arrowPosition: 'top' | 'right' | 'bottom' | 'top-right' | 'top-left' = 'top';
|
||||
|
||||
txIdShort = '';
|
||||
confirmations = 0;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnChanges() {
|
||||
if (this.tx) {
|
||||
this.txIdShort = this.tx.txid.substring(0, 6) + '...' + this.tx.txid.substring(this.tx.txid.length - 6);
|
||||
}
|
||||
this.confirmations = (this.latestBlockHeight - this.txTrackingBlockHeight) + 1;
|
||||
}
|
||||
}
|
0
frontend/src/assets/.gitkeep
Normal file
BIN
frontend/src/assets/btc-qr-code-segwit.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
frontend/src/assets/btc-qr-code.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
frontend/src/assets/divider-new.png
Normal file
After Width: | Height: | Size: 81 B |
BIN
frontend/src/assets/favicon/android-icon-144x144.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/android-icon-192x192.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
frontend/src/assets/favicon/android-icon-36x36.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/src/assets/favicon/android-icon-48x48.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/src/assets/favicon/android-icon-72x72.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/src/assets/favicon/android-icon-96x96.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/src/assets/favicon/apple-icon-114x114.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
frontend/src/assets/favicon/apple-icon-120x120.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/src/assets/favicon/apple-icon-144x144.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/apple-icon-152x152.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/apple-icon-180x180.png
Normal file
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/src/assets/favicon/apple-icon-57x57.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/src/assets/favicon/apple-icon-60x60.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/src/assets/favicon/apple-icon-72x72.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/src/assets/favicon/apple-icon-76x76.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/src/assets/favicon/apple-icon-precomposed.png
Normal file
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/src/assets/favicon/apple-icon.png
Normal file
After Width: | Height: | Size: 4.9 KiB |
2
frontend/src/assets/favicon/browserconfig.xml
Normal file
@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
BIN
frontend/src/assets/favicon/favicon-16x16.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/src/assets/favicon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/src/assets/favicon/favicon-96x96.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/src/assets/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 1.1 KiB |
41
frontend/src/assets/favicon/manifest.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
BIN
frontend/src/assets/favicon/ms-icon-144x144.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/ms-icon-150x150.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/ms-icon-310x310.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/src/assets/favicon/ms-icon-70x70.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/src/assets/mempool-space-logo.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
BIN
frontend/src/assets/mempool-tube.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/src/assets/paynym-code.png
Normal file
After Width: | Height: | Size: 62 KiB |
9
frontend/src/browserslist
Normal file
@ -0,0 +1,9 @@
|
||||
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
# For IE 9-11 support, please uncomment the last line of the file and adjust as needed
|
||||
> 0.5%
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
# IE 9-11
|
3
frontend/src/environments/environment.prod.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
};
|
15
frontend/src/environments/environment.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false
|
||||
};
|
||||
|
||||
/*
|
||||
* In development mode, to ignore zone related error stack frames such as
|
||||
* `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can
|
||||
* import the following file, but please comment it out in production mode
|
||||
* because it will have performance impact when throw error
|
||||
*/
|
||||
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
|