From f3fc774c2d3f5de657e00414ab37a654b20e7605 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 21 Sep 2023 21:57:54 +0100 Subject: [PATCH 1/3] Add standalone block visualization page --- frontend/src/app/app-routing.module.ts | 5 + .../block-view/block-view.component.html | 13 ++ .../block-view/block-view.component.scss | 22 +++ .../block-view/block-view.component.ts | 176 ++++++++++++++++++ frontend/src/app/shared/shared.module.ts | 3 + 5 files changed, 219 insertions(+) create mode 100644 frontend/src/app/components/block-view/block-view.component.html create mode 100644 frontend/src/app/components/block-view/block-view.component.scss create mode 100644 frontend/src/app/components/block-view/block-view.component.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 79a8e1c02..7ca9e107b 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -4,6 +4,7 @@ import { AppPreloadingStrategy } from './app.preloading-strategy' import { StartComponent } from './components/start/start.component'; import { TransactionComponent } from './components/transaction/transaction.component'; import { BlockComponent } from './components/block/block.component'; +import { BlockViewComponent } from './components/block-view/block-view.component'; import { ClockComponent } from './components/clock/clock.component'; import { AddressComponent } from './components/address/address.component'; import { MasterPageComponent } from './components/master-page/master-page.component'; @@ -373,6 +374,10 @@ let routes: Routes = [ path: 'clock/:mode/:index', component: ClockComponent, }, + { + path: 'view/block/:id', + component: BlockViewComponent, + }, { path: 'status', data: { networks: ['bitcoin', 'liquid'] }, diff --git a/frontend/src/app/components/block-view/block-view.component.html b/frontend/src/app/components/block-view/block-view.component.html new file mode 100644 index 000000000..905c69198 --- /dev/null +++ b/frontend/src/app/components/block-view/block-view.component.html @@ -0,0 +1,13 @@ +
+
+ +
+
diff --git a/frontend/src/app/components/block-view/block-view.component.scss b/frontend/src/app/components/block-view/block-view.component.scss new file mode 100644 index 000000000..782d416d8 --- /dev/null +++ b/frontend/src/app/components/block-view/block-view.component.scss @@ -0,0 +1,22 @@ +.block-wrapper { + width: 100vw; + height: 100vh; + background: #181b2d; +} + +.block-container { + flex-grow: 0; + flex-shrink: 0; + width: 100vw; + max-width: 100vh; + height: 100vh; + padding: 0; + margin: auto; + display: flex; + justify-content: center; + align-items: center; + + * { + flex-grow: 1; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/block-view/block-view.component.ts b/frontend/src/app/components/block-view/block-view.component.ts new file mode 100644 index 000000000..ef1a7247b --- /dev/null +++ b/frontend/src/app/components/block-view/block-view.component.ts @@ -0,0 +1,176 @@ +import { Component, OnInit, OnDestroy, ViewChild, HostListener } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { switchMap, tap, throttleTime, catchError, shareReplay, startWith, pairwise, filter } from 'rxjs/operators'; +import { of, Subscription, asyncScheduler } from 'rxjs'; +import { StateService } from '../../services/state.service'; +import { SeoService } from '../../services/seo.service'; +import { OpenGraphService } from '../../services/opengraph.service'; +import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; +import { ApiService } from '../../services/api.service'; +import { seoDescriptionNetwork } from '../../shared/common.utils'; +import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component'; + +function bestFitResolution(min, max, n) { + const target = (min + max) / 2; + let bestScore = Infinity; + let best = null; + for (let i = min; i <= max; i++) { + const remainder = (n % i); + if (remainder < bestScore || (remainder === bestScore && (Math.abs(i - target) < Math.abs(best - target)))) { + bestScore = remainder; + best = i; + } + } + return best; +} + +@Component({ + selector: 'app-block-view', + templateUrl: './block-view.component.html', + styleUrls: ['./block-view.component.scss'] +}) +export class BlockViewComponent implements OnInit, OnDestroy { + network = ''; + block: BlockExtended; + blockHeight: number; + blockHash: string; + rawId: string; + isLoadingBlock = true; + strippedTransactions: TransactionStripped[]; + isLoadingOverview = true; + autofit: boolean = false; + resolution: number = 80; + + overviewSubscription: Subscription; + networkChangedSubscription: Subscription; + queryParamsSubscription: Subscription; + + @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; + + constructor( + private route: ActivatedRoute, + private electrsApiService: ElectrsApiService, + public stateService: StateService, + private seoService: SeoService, + private openGraphService: OpenGraphService, + private apiService: ApiService + ) { } + + ngOnInit() { + this.network = this.stateService.network; + + this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { + this.autofit = params.autofit === 'true'; + if (this.autofit) { + this.onResize(); + } + }); + + const block$ = this.route.paramMap.pipe( + switchMap((params: ParamMap) => { + this.rawId = params.get('id') || ''; + + const blockHash: string = params.get('id') || ''; + this.block = undefined; + + let isBlockHeight = false; + if (/^[0-9]+$/.test(blockHash)) { + isBlockHeight = true; + } else { + this.blockHash = blockHash; + } + + this.isLoadingBlock = true; + this.isLoadingOverview = true; + + if (isBlockHeight) { + return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockHash, 10)) + .pipe( + switchMap((hash) => { + if (hash) { + this.blockHash = hash; + return this.apiService.getBlock$(hash); + } else { + return null; + } + }), + catchError(() => { + return of(null); + }), + ); + } + return this.apiService.getBlock$(blockHash); + }), + filter((block: BlockExtended | void) => block != null), + tap((block: BlockExtended) => { + this.block = block; + this.blockHeight = block.height; + + this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`); + if( this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ) { + this.seoService.setDescription($localize`:@@meta.description.liquid.block:See size, weight, fee range, included transactions, and more for Liquid${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); + } else { + this.seoService.setDescription($localize`:@@meta.description.bitcoin.block:See size, weight, fee range, included transactions, audit (expected v actual), and more for Bitcoin${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); + } + this.isLoadingBlock = false; + this.isLoadingOverview = true; + }), + shareReplay(1) + ); + + this.overviewSubscription = block$.pipe( + switchMap((block) => this.apiService.getStrippedBlockTransactions$(block.id) + .pipe( + catchError(() => { + return of([]); + }), + switchMap((transactions) => { + return of(transactions); + }) + ) + ), + ) + .subscribe((transactions: TransactionStripped[]) => { + this.strippedTransactions = transactions; + this.isLoadingOverview = false; + if (this.blockGraph) { + this.blockGraph.destroy(); + this.blockGraph.setup(this.strippedTransactions); + } + }, + () => { + this.isLoadingOverview = false; + if (this.blockGraph) { + this.blockGraph.destroy(); + } + }); + + this.networkChangedSubscription = this.stateService.networkChanged$ + .subscribe((network) => this.network = network); + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + if (this.autofit) { + this.resolution = bestFitResolution(64, 96, Math.min(window.innerWidth, window.innerHeight)); + console.log('resized, new resolution ', this.resolution, window.innerWidth, window.innerHeight); + // if (this.blockGraph && this.strippedTransactions) { + // this.blockGraph.destroy(); + // this.blockGraph.setup(this.strippedTransactions); + // } + } + } + + ngOnDestroy() { + if (this.overviewSubscription) { + this.overviewSubscription.unsubscribe(); + } + if (this.networkChangedSubscription) { + this.networkChangedSubscription.unsubscribe(); + } + if (this.queryParamsSubscription) { + this.queryParamsSubscription.unsubscribe(); + } + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index f7c253a96..bba70a2ce 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -97,6 +97,7 @@ import { AcceleratePreviewComponent } from '../components/accelerate-preview/acc import { AccelerateFeeGraphComponent } from '../components/accelerate-preview/accelerate-fee-graph.component'; import { MempoolErrorComponent } from './components/mempool-error/mempool-error.component'; +import { BlockViewComponent } from '../components/block-view/block-view.component'; import { MempoolBlockOverviewComponent } from '../components/mempool-block-overview/mempool-block-overview.component'; import { ClockchainComponent } from '../components/clockchain/clockchain.component'; import { ClockFaceComponent } from '../components/clock-face/clock-face.component'; @@ -134,6 +135,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir FiatCurrencyPipe, ColoredPriceDirective, BlockchainComponent, + BlockViewComponent, MempoolBlocksComponent, BlockchainBlocksComponent, AmountComponent, @@ -196,6 +198,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir AccelerateFeeGraphComponent, CalculatorComponent, BitcoinsatoshisPipe, + BlockViewComponent, MempoolBlockOverviewComponent, ClockchainComponent, ClockComponent, From 72750267d01922f0e189e8105c12e5f48c2e6001 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 23 Sep 2023 22:25:22 +0100 Subject: [PATCH 2/3] Enable navigation from standalone block page --- .../block-view/block-view.component.html | 1 + .../block-view/block-view.component.ts | 30 +++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/components/block-view/block-view.component.html b/frontend/src/app/components/block-view/block-view.component.html index 905c69198..9a2ddf373 100644 --- a/frontend/src/app/components/block-view/block-view.component.html +++ b/frontend/src/app/components/block-view/block-view.component.html @@ -8,6 +8,7 @@ [orientation]="'top'" [flip]="false" [disableSpinner]="true" + (txClickEvent)="onTxClick($event)" > diff --git a/frontend/src/app/components/block-view/block-view.component.ts b/frontend/src/app/components/block-view/block-view.component.ts index ef1a7247b..5c3b7719c 100644 --- a/frontend/src/app/components/block-view/block-view.component.ts +++ b/frontend/src/app/components/block-view/block-view.component.ts @@ -1,17 +1,17 @@ import { Component, OnInit, OnDestroy, ViewChild, HostListener } from '@angular/core'; -import { ActivatedRoute, ParamMap } from '@angular/router'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; -import { switchMap, tap, throttleTime, catchError, shareReplay, startWith, pairwise, filter } from 'rxjs/operators'; -import { of, Subscription, asyncScheduler } from 'rxjs'; +import { switchMap, tap, catchError, shareReplay, filter } from 'rxjs/operators'; +import { of, Subscription } from 'rxjs'; import { StateService } from '../../services/state.service'; import { SeoService } from '../../services/seo.service'; -import { OpenGraphService } from '../../services/opengraph.service'; import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { seoDescriptionNetwork } from '../../shared/common.utils'; import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component'; +import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; -function bestFitResolution(min, max, n) { +function bestFitResolution(min, max, n): number { const target = (min + max) / 2; let bestScore = Infinity; let best = null; @@ -50,14 +50,14 @@ export class BlockViewComponent implements OnInit, OnDestroy { constructor( private route: ActivatedRoute, + private router: Router, private electrsApiService: ElectrsApiService, public stateService: StateService, private seoService: SeoService, - private openGraphService: OpenGraphService, private apiService: ApiService ) { } - ngOnInit() { + ngOnInit(): void { this.network = this.stateService.network; this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { @@ -150,19 +150,23 @@ export class BlockViewComponent implements OnInit, OnDestroy { .subscribe((network) => this.network = network); } + onTxClick(event: { tx: TransactionStripped, keyModifier: boolean }): void { + const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.tx.txid}`); + if (!event.keyModifier) { + this.router.navigate([url]); + } else { + window.open(url, '_blank'); + } + } + @HostListener('window:resize', ['$event']) onResize(): void { if (this.autofit) { this.resolution = bestFitResolution(64, 96, Math.min(window.innerWidth, window.innerHeight)); - console.log('resized, new resolution ', this.resolution, window.innerWidth, window.innerHeight); - // if (this.blockGraph && this.strippedTransactions) { - // this.blockGraph.destroy(); - // this.blockGraph.setup(this.strippedTransactions); - // } } } - ngOnDestroy() { + ngOnDestroy(): void { if (this.overviewSubscription) { this.overviewSubscription.unsubscribe(); } From 6773af92edce65739570682dc55ffca369b6ed74 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 23 Sep 2023 23:09:11 +0100 Subject: [PATCH 3/3] Add standalone mempool block visualization page --- frontend/src/app/app-routing.module.ts | 5 ++ .../mempool-block-view.component.html | 5 ++ .../mempool-block-view.component.scss | 22 +++++ .../mempool-block-view.component.ts | 85 +++++++++++++++++++ frontend/src/app/shared/shared.module.ts | 3 + 5 files changed, 120 insertions(+) create mode 100644 frontend/src/app/components/mempool-block-view/mempool-block-view.component.html create mode 100644 frontend/src/app/components/mempool-block-view/mempool-block-view.component.scss create mode 100644 frontend/src/app/components/mempool-block-view/mempool-block-view.component.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 7ca9e107b..7c2ac1274 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -5,6 +5,7 @@ import { StartComponent } from './components/start/start.component'; import { TransactionComponent } from './components/transaction/transaction.component'; import { BlockComponent } from './components/block/block.component'; import { BlockViewComponent } from './components/block-view/block-view.component'; +import { MempoolBlockViewComponent } from './components/mempool-block-view/mempool-block-view.component'; import { ClockComponent } from './components/clock/clock.component'; import { AddressComponent } from './components/address/address.component'; import { MasterPageComponent } from './components/master-page/master-page.component'; @@ -378,6 +379,10 @@ let routes: Routes = [ path: 'view/block/:id', component: BlockViewComponent, }, + { + path: 'view/mempool-block/:index', + component: MempoolBlockViewComponent, + }, { path: 'status', data: { networks: ['bitcoin', 'liquid'] }, diff --git a/frontend/src/app/components/mempool-block-view/mempool-block-view.component.html b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.html new file mode 100644 index 000000000..9d51ff4e9 --- /dev/null +++ b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.html @@ -0,0 +1,5 @@ +
+
+ +
+
\ No newline at end of file diff --git a/frontend/src/app/components/mempool-block-view/mempool-block-view.component.scss b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.scss new file mode 100644 index 000000000..782d416d8 --- /dev/null +++ b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.scss @@ -0,0 +1,22 @@ +.block-wrapper { + width: 100vw; + height: 100vh; + background: #181b2d; +} + +.block-container { + flex-grow: 0; + flex-shrink: 0; + width: 100vw; + max-width: 100vh; + height: 100vh; + padding: 0; + margin: auto; + display: flex; + justify-content: center; + align-items: center; + + * { + flex-grow: 1; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/mempool-block-view/mempool-block-view.component.ts b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.ts new file mode 100644 index 000000000..ebeb0801c --- /dev/null +++ b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit, OnDestroy, HostListener } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { Subscription, filter, map, switchMap, tap } from 'rxjs'; +import { StateService } from '../../services/state.service'; +import { WebsocketService } from '../../services/websocket.service'; + +function bestFitResolution(min, max, n): number { + const target = (min + max) / 2; + let bestScore = Infinity; + let best = null; + for (let i = min; i <= max; i++) { + const remainder = (n % i); + if (remainder < bestScore || (remainder === bestScore && (Math.abs(i - target) < Math.abs(best - target)))) { + bestScore = remainder; + best = i; + } + } + return best; +} + +@Component({ + selector: 'app-mempool-block-view', + templateUrl: './mempool-block-view.component.html', + styleUrls: ['./mempool-block-view.component.scss'] +}) +export class MempoolBlockViewComponent implements OnInit, OnDestroy { + autofit: boolean = false; + resolution: number = 80; + index: number = 0; + + routeParamsSubscription: Subscription; + queryParamsSubscription: Subscription; + + constructor( + private route: ActivatedRoute, + private websocketService: WebsocketService, + public stateService: StateService, + ) { } + + ngOnInit(): void { + this.websocketService.want(['blocks', 'mempool-blocks']); + + this.routeParamsSubscription = this.route.paramMap + .pipe( + switchMap((params: ParamMap) => { + this.index = parseInt(params.get('index'), 10) || 0; + return this.stateService.mempoolBlocks$ + .pipe( + map((blocks) => { + if (!blocks.length) { + return [{ index: 0, blockSize: 0, blockVSize: 0, feeRange: [0, 0], medianFee: 0, nTx: 0, totalFees: 0 }]; + } + return blocks; + }), + filter((mempoolBlocks) => mempoolBlocks.length > 0), + tap((mempoolBlocks) => { + while (!mempoolBlocks[this.index]) { + this.index--; + } + }) + ); + }) + ).subscribe(); + + this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { + this.autofit = params.autofit === 'true'; + if (this.autofit) { + this.onResize(); + } + }); + } + + + @HostListener('window:resize', ['$event']) + onResize(): void { + if (this.autofit) { + this.resolution = bestFitResolution(64, 96, Math.min(window.innerWidth, window.innerHeight)); + } + } + + ngOnDestroy(): void { + this.routeParamsSubscription.unsubscribe(); + this.queryParamsSubscription.unsubscribe(); + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index bba70a2ce..dce65bfae 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -98,6 +98,7 @@ import { AccelerateFeeGraphComponent } from '../components/accelerate-preview/ac import { MempoolErrorComponent } from './components/mempool-error/mempool-error.component'; import { BlockViewComponent } from '../components/block-view/block-view.component'; +import { MempoolBlockViewComponent } from '../components/mempool-block-view/mempool-block-view.component'; import { MempoolBlockOverviewComponent } from '../components/mempool-block-overview/mempool-block-overview.component'; import { ClockchainComponent } from '../components/clockchain/clockchain.component'; import { ClockFaceComponent } from '../components/clock-face/clock-face.component'; @@ -136,6 +137,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir ColoredPriceDirective, BlockchainComponent, BlockViewComponent, + MempoolBlockViewComponent, MempoolBlocksComponent, BlockchainBlocksComponent, AmountComponent, @@ -199,6 +201,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir CalculatorComponent, BitcoinsatoshisPipe, BlockViewComponent, + MempoolBlockViewComponent, MempoolBlockOverviewComponent, ClockchainComponent, ClockComponent,