From e4f3642082721d87901794364049da09e64fa7bd Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 15 Mar 2023 11:43:18 +0900 Subject: [PATCH 1/4] Redesign mempool block fee distribution graph --- .../fee-distribution-graph.component.ts | 74 +++++++++++++++++-- .../mempool-block.component.html | 4 +- .../mempool-block/mempool-block.component.ts | 3 + frontend/src/app/services/state.service.ts | 29 +++++++- frontend/src/styles.scss | 2 +- 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts index 8c90036fd..e75719191 100644 --- a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts +++ b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts @@ -1,5 +1,8 @@ import { OnChanges } from '@angular/core'; import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { TransactionStripped } from '../../interfaces/websocket.interface'; +import { StateService } from '../../services/state.service'; +import { VbytesPipe } from '../../shared/pipes/bytes-pipe/vbytes.pipe'; @Component({ selector: 'app-fee-distribution-graph', @@ -7,41 +10,99 @@ import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core changeDetection: ChangeDetectionStrategy.OnPush, }) export class FeeDistributionGraphComponent implements OnInit, OnChanges { - @Input() data: any; + @Input() transactions: TransactionStripped[]; @Input() height: number | string = 210; @Input() top: number | string = 20; @Input() right: number | string = 22; @Input() left: number | string = 30; + @Input() numSamples: number = 200; + @Input() numLabels: number = 10; + + data: number[][]; + labelInterval: number = 50; mempoolVsizeFeesOptions: any; mempoolVsizeFeesInitOptions = { renderer: 'svg' }; - constructor() { } + constructor( + private stateService: StateService, + private vbytesPipe: VbytesPipe, + ) { } ngOnInit() { this.mountChart(); } ngOnChanges() { + this.prepareChart(); this.mountChart(); } + prepareChart() { + this.data = []; + if (!this.transactions?.length) { + return; + } + const samples = []; + const txs = this.transactions.map(tx => { return { vsize: tx.vsize, rate: tx.rate || (tx.fee / tx.vsize) }; }).sort((a, b) => { return b.rate - a.rate; }); + const maxBlockVSize = this.stateService.env.BLOCK_WEIGHT_UNITS / 4; + const sampleInterval = maxBlockVSize / this.numSamples; + let cumVSize = 0; + let sampleIndex = 0; + let nextSample = 0; + let txIndex = 0; + this.labelInterval = this.numSamples / this.numLabels; + while (nextSample <= maxBlockVSize) { + if (txIndex >= txs.length) { + samples.push([1 - (sampleIndex / this.numSamples), 0]); + nextSample += sampleInterval; + sampleIndex++; + break; + } + + while (txs[txIndex] && nextSample < cumVSize + txs[txIndex].vsize) { + samples.push([1 - (sampleIndex / this.numSamples), txs[txIndex].rate]); + nextSample += sampleInterval; + sampleIndex++; + } + cumVSize += txs[txIndex].vsize; + txIndex++; + } + this.data = samples.reverse(); + } + mountChart() { this.mempoolVsizeFeesOptions = { grid: { height: '210', right: '20', top: '22', - left: '30', + left: '40', }, xAxis: { type: 'category', boundaryGap: false, + name: 'MvB', + nameLocation: 'middle', + nameGap: 0, + nameTextStyle: { + verticalAlign: 'top', + padding: [30, 0, 0, 0], + }, + axisLabel: { + interval: (index: number): boolean => { return index && (index % this.labelInterval === 0); }, + formatter: (value: number): string => { return Number(value).toFixed(1); }, + }, + axisTick: { + interval: (index:number): boolean => { return (index % this.labelInterval === 0); }, + }, }, yAxis: { type: 'value', + // name: 'Effective Fee Rate s/vb', + // nameLocation: 'middle', splitLine: { lineStyle: { type: 'dotted', @@ -58,14 +119,13 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { position: 'top', color: '#ffffff', textShadowBlur: 0, - formatter: (label: any) => { - return Math.floor(label.data); - }, + formatter: (label: any): string => '' + Math.floor(label.data[1]), }, + showAllSymbol: false, smooth: true, lineStyle: { color: '#D81B60', - width: 4, + width: 1, }, itemStyle: { color: '#b71c1c', diff --git a/frontend/src/app/components/mempool-block/mempool-block.component.html b/frontend/src/app/components/mempool-block/mempool-block.component.html index 3626e6ff5..66d024b8c 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.html +++ b/frontend/src/app/components/mempool-block/mempool-block.component.html @@ -39,11 +39,11 @@ - +
- +
diff --git a/frontend/src/app/components/mempool-block/mempool-block.component.ts b/frontend/src/app/components/mempool-block/mempool-block.component.ts index b9bdc55bb..cb3a38172 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.ts +++ b/frontend/src/app/components/mempool-block/mempool-block.component.ts @@ -17,6 +17,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { network$: Observable; mempoolBlockIndex: number; mempoolBlock$: Observable; + mempoolBlockTransactions$: Observable; ordinal$: BehaviorSubject = new BehaviorSubject(''); previewTx: TransactionStripped | void; webGlEnabled: boolean; @@ -62,6 +63,8 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { }) ); + this.mempoolBlockTransactions$ = this.stateService.liveMempoolBlockTransactions$.pipe(map(txMap => Object.values(txMap))); + this.network$ = this.stateService.networkChanged$; } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 31f5f3aab..fb3b37e05 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,11 +1,11 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; -import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; +import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable, merge } from 'rxjs'; import { Transaction } from '../interfaces/electrs.interface'; import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; import { BlockExtended, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; -import { map, shareReplay } from 'rxjs/operators'; +import { map, scan, shareReplay, tap } from 'rxjs/operators'; import { StorageService } from './storage.service'; interface MarkBlockState { @@ -100,6 +100,7 @@ export class StateService { mempoolBlocks$ = new ReplaySubject(1); mempoolBlockTransactions$ = new Subject(); mempoolBlockDelta$ = new Subject(); + liveMempoolBlockTransactions$: Observable<{ [txid: string]: TransactionStripped}>; txReplaced$ = new Subject(); txRbfInfo$ = new Subject(); rbfLatest$ = new Subject(); @@ -166,6 +167,30 @@ export class StateService { this.blocks$ = new ReplaySubject<[BlockExtended, string]>(this.env.KEEP_BLOCKS_AMOUNT); + this.liveMempoolBlockTransactions$ = merge( + this.mempoolBlockTransactions$.pipe(map(transactions => { return { transactions }; })), + this.mempoolBlockDelta$.pipe(map(delta => { return { delta }; })), + ).pipe(scan((transactions: { [txid: string]: TransactionStripped }, change: any): { [txid: string]: TransactionStripped } => { + if (change.transactions) { + const txMap = {} + change.transactions.forEach(tx => { + txMap[tx.txid] = tx; + }) + return txMap; + } else { + change.delta.changed.forEach(tx => { + transactions[tx.txid].rate = tx.rate; + }) + change.delta.removed.forEach(txid => { + delete transactions[txid]; + }); + change.delta.added.forEach(tx => { + transactions[tx.txid] = tx; + }); + return transactions; + } + }, {})); + if (this.env.BASE_MODULE === 'bisq') { this.network = this.env.BASE_MODULE; this.networkChanged$.next(this.env.BASE_MODULE); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index b38a60898..aea8e8d6e 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -500,7 +500,7 @@ html:lang(ru) .card-title { } .fee-distribution-chart { - height: 250px; + height: 265px; } .fees-wrapper-tooltip-chart { From 9f2b98b246e184b4cf140fedbe7ccc7741cf3c67 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 7 Jun 2023 13:22:27 -0400 Subject: [PATCH 2/4] Handle stack-of-N-blocks in new fee graph --- backend/src/api/mempool-blocks.ts | 4 ++-- .../fee-distribution-graph.component.ts | 17 +++++++++++++---- .../mempool-block/mempool-block.component.html | 4 ++-- .../mempool-block/mempool-block.component.ts | 1 + 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index 224e31744..57d1a393f 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -143,7 +143,7 @@ class MempoolBlocks { const stackWeight = transactionsSorted.slice(index).reduce((total, tx) => total + (tx.weight || 0), 0); if (stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS) { onlineStats = true; - feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5); + feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]); feeStatsCalculator.processNext(tx); } } @@ -334,7 +334,7 @@ class MempoolBlocks { if (hasBlockStack) { stackWeight = blocks[blocks.length - 1].reduce((total, tx) => total + (mempool[tx]?.weight || 0), 0); hasBlockStack = stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS; - feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5); + feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]); } const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees, feeStats }[] = []; diff --git a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts index e75719191..d20a9612f 100644 --- a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts +++ b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts @@ -10,6 +10,8 @@ import { VbytesPipe } from '../../shared/pipes/bytes-pipe/vbytes.pipe'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class FeeDistributionGraphComponent implements OnInit, OnChanges { + @Input() feeRange: number[]; + @Input() vsize: number; @Input() transactions: TransactionStripped[]; @Input() height: number | string = 210; @Input() top: number | string = 20; @@ -18,6 +20,7 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { @Input() numSamples: number = 200; @Input() numLabels: number = 10; + simple: boolean = false; data: number[][]; labelInterval: number = 50; @@ -36,11 +39,17 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { } ngOnChanges() { + this.simple = !!this.feeRange?.length; this.prepareChart(); this.mountChart(); } prepareChart() { + if (this.simple) { + this.data = this.feeRange.map((rate, index) => [index * 10, rate]); + this.labelInterval = 1; + return; + } this.data = []; if (!this.transactions?.length) { return; @@ -56,14 +65,14 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { this.labelInterval = this.numSamples / this.numLabels; while (nextSample <= maxBlockVSize) { if (txIndex >= txs.length) { - samples.push([1 - (sampleIndex / this.numSamples), 0]); + samples.push([(1 - (sampleIndex / this.numSamples)) * 100, 0]); nextSample += sampleInterval; sampleIndex++; break; } while (txs[txIndex] && nextSample < cumVSize + txs[txIndex].vsize) { - samples.push([1 - (sampleIndex / this.numSamples), txs[txIndex].rate]); + samples.push([(1 - (sampleIndex / this.numSamples)) * 100, txs[txIndex].rate]); nextSample += sampleInterval; sampleIndex++; } @@ -84,7 +93,7 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { xAxis: { type: 'category', boundaryGap: false, - name: 'MvB', + name: '% Weight', nameLocation: 'middle', nameGap: 0, nameTextStyle: { @@ -93,7 +102,7 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { }, axisLabel: { interval: (index: number): boolean => { return index && (index % this.labelInterval === 0); }, - formatter: (value: number): string => { return Number(value).toFixed(1); }, + formatter: (value: number): string => { return Number(value).toFixed(0); }, }, axisTick: { interval: (index:number): boolean => { return (index % this.labelInterval === 0); }, diff --git a/frontend/src/app/components/mempool-block/mempool-block.component.html b/frontend/src/app/components/mempool-block/mempool-block.component.html index 66d024b8c..7d5b18ccb 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.html +++ b/frontend/src/app/components/mempool-block/mempool-block.component.html @@ -39,11 +39,11 @@ - +
- +
diff --git a/frontend/src/app/components/mempool-block/mempool-block.component.ts b/frontend/src/app/components/mempool-block/mempool-block.component.ts index cb3a38172..6e0b21196 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.ts +++ b/frontend/src/app/components/mempool-block/mempool-block.component.ts @@ -54,6 +54,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { const ordinal = this.getOrdinal(mempoolBlocks[this.mempoolBlockIndex]); this.ordinal$.next(ordinal); this.seoService.setTitle(ordinal); + mempoolBlocks[this.mempoolBlockIndex].isStack = mempoolBlocks[this.mempoolBlockIndex].blockVSize > this.stateService.blockVSize; return mempoolBlocks[this.mempoolBlockIndex]; }) ); From 86f51e39020401b48a0c8c3195f0153a73ddecef Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 12 Jun 2023 11:54:58 -0400 Subject: [PATCH 3/4] fix fee graph for underfilled blocks --- .../fee-distribution-graph.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts index d20a9612f..d41aa444d 100644 --- a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts +++ b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts @@ -68,7 +68,7 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { samples.push([(1 - (sampleIndex / this.numSamples)) * 100, 0]); nextSample += sampleInterval; sampleIndex++; - break; + continue; } while (txs[txIndex] && nextSample < cumVSize + txs[txIndex].vsize) { @@ -128,7 +128,7 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { position: 'top', color: '#ffffff', textShadowBlur: 0, - formatter: (label: any): string => '' + Math.floor(label.data[1]), + formatter: (label: any): string => '' + (label.data[1] > 99.5 ? Math.round(label.data[1]) : label.data[1].toFixed(1)), }, showAllSymbol: false, smooth: true, From eca40f94c9f62dbedf618c306f424f954dae4fa7 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 30 Jun 2023 19:41:12 -0400 Subject: [PATCH 4/4] use power-of-ten formatting for large fee rates --- .../fee-distribution-graph.component.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts index d41aa444d..823d271a1 100644 --- a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts +++ b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts @@ -3,6 +3,7 @@ import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core import { TransactionStripped } from '../../interfaces/websocket.interface'; import { StateService } from '../../services/state.service'; import { VbytesPipe } from '../../shared/pipes/bytes-pipe/vbytes.pipe'; +import { selectPowerOfTen } from '../../bitcoin.utils'; @Component({ selector: 'app-fee-distribution-graph', @@ -34,17 +35,17 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { private vbytesPipe: VbytesPipe, ) { } - ngOnInit() { + ngOnInit(): void { this.mountChart(); } - ngOnChanges() { + ngOnChanges(): void { this.simple = !!this.feeRange?.length; this.prepareChart(); this.mountChart(); } - prepareChart() { + prepareChart(): void { if (this.simple) { this.data = this.feeRange.map((rate, index) => [index * 10, rate]); this.labelInterval = 1; @@ -82,7 +83,7 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { this.data = samples.reverse(); } - mountChart() { + mountChart(): void { this.mempoolVsizeFeesOptions = { grid: { height: '210', @@ -118,6 +119,13 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { color: '#ffffff66', opacity: 0.25, } + }, + axisLabel: { + formatter: (value: number): string => { + const selectedPowerOfTen = selectPowerOfTen(value); + const newVal = Math.round(value / selectedPowerOfTen.divider); + return `${newVal}${selectedPowerOfTen.unit}`; + }, } }, series: [{ @@ -128,7 +136,12 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { position: 'top', color: '#ffffff', textShadowBlur: 0, - formatter: (label: any): string => '' + (label.data[1] > 99.5 ? Math.round(label.data[1]) : label.data[1].toFixed(1)), + formatter: (label: { data: number[] }): string => { + const value = label.data[1]; + const selectedPowerOfTen = selectPowerOfTen(value); + const newVal = Math.round(value / selectedPowerOfTen.divider); + return `${newVal}${selectedPowerOfTen.unit}`; + }, }, showAllSymbol: false, smooth: true,