Merge pull request #4280 from mempool/mononaut/standalone-visualizations
Standalone block visualizations
This commit is contained in:
commit
27077dcd6c
@ -4,6 +4,8 @@ import { AppPreloadingStrategy } from './app.preloading-strategy'
|
|||||||
import { StartComponent } from './components/start/start.component';
|
import { StartComponent } from './components/start/start.component';
|
||||||
import { TransactionComponent } from './components/transaction/transaction.component';
|
import { TransactionComponent } from './components/transaction/transaction.component';
|
||||||
import { BlockComponent } from './components/block/block.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 { ClockComponent } from './components/clock/clock.component';
|
||||||
import { AddressComponent } from './components/address/address.component';
|
import { AddressComponent } from './components/address/address.component';
|
||||||
import { MasterPageComponent } from './components/master-page/master-page.component';
|
import { MasterPageComponent } from './components/master-page/master-page.component';
|
||||||
@ -373,6 +375,14 @@ let routes: Routes = [
|
|||||||
path: 'clock/:mode/:index',
|
path: 'clock/:mode/:index',
|
||||||
component: ClockComponent,
|
component: ClockComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'view/block/:id',
|
||||||
|
component: BlockViewComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'view/mempool-block/:index',
|
||||||
|
component: MempoolBlockViewComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'status',
|
path: 'status',
|
||||||
data: { networks: ['bitcoin', 'liquid'] },
|
data: { networks: ['bitcoin', 'liquid'] },
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
<div class="block-wrapper">
|
||||||
|
<div class="block-container">
|
||||||
|
<app-block-overview-graph
|
||||||
|
#blockGraph
|
||||||
|
[isLoading]="false"
|
||||||
|
[resolution]="resolution"
|
||||||
|
[blockLimit]="stateService.blockVSize"
|
||||||
|
[orientation]="'top'"
|
||||||
|
[flip]="false"
|
||||||
|
[disableSpinner]="true"
|
||||||
|
(txClickEvent)="onTxClick($event)"
|
||||||
|
></app-block-overview-graph>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
180
frontend/src/app/components/block-view/block-view.component.ts
Normal file
180
frontend/src/app/components/block-view/block-view.component.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, ViewChild, HostListener } from '@angular/core';
|
||||||
|
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
|
||||||
|
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||||
|
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 { 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): 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-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 router: Router,
|
||||||
|
private electrsApiService: ElectrsApiService,
|
||||||
|
public stateService: StateService,
|
||||||
|
private seoService: SeoService,
|
||||||
|
private apiService: ApiService
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.overviewSubscription) {
|
||||||
|
this.overviewSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
if (this.networkChangedSubscription) {
|
||||||
|
this.networkChangedSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
if (this.queryParamsSubscription) {
|
||||||
|
this.queryParamsSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
<div class="block-wrapper">
|
||||||
|
<div class="block-container">
|
||||||
|
<app-mempool-block-overview [index]="index"></app-mempool-block-overview>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -97,6 +97,8 @@ import { AcceleratePreviewComponent } from '../components/accelerate-preview/acc
|
|||||||
import { AccelerateFeeGraphComponent } from '../components/accelerate-preview/accelerate-fee-graph.component';
|
import { AccelerateFeeGraphComponent } from '../components/accelerate-preview/accelerate-fee-graph.component';
|
||||||
import { MempoolErrorComponent } from './components/mempool-error/mempool-error.component';
|
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 { MempoolBlockOverviewComponent } from '../components/mempool-block-overview/mempool-block-overview.component';
|
||||||
import { ClockchainComponent } from '../components/clockchain/clockchain.component';
|
import { ClockchainComponent } from '../components/clockchain/clockchain.component';
|
||||||
import { ClockFaceComponent } from '../components/clock-face/clock-face.component';
|
import { ClockFaceComponent } from '../components/clock-face/clock-face.component';
|
||||||
@ -134,6 +136,8 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
|
|||||||
FiatCurrencyPipe,
|
FiatCurrencyPipe,
|
||||||
ColoredPriceDirective,
|
ColoredPriceDirective,
|
||||||
BlockchainComponent,
|
BlockchainComponent,
|
||||||
|
BlockViewComponent,
|
||||||
|
MempoolBlockViewComponent,
|
||||||
MempoolBlocksComponent,
|
MempoolBlocksComponent,
|
||||||
BlockchainBlocksComponent,
|
BlockchainBlocksComponent,
|
||||||
AmountComponent,
|
AmountComponent,
|
||||||
@ -196,6 +200,8 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
|
|||||||
AccelerateFeeGraphComponent,
|
AccelerateFeeGraphComponent,
|
||||||
CalculatorComponent,
|
CalculatorComponent,
|
||||||
BitcoinsatoshisPipe,
|
BitcoinsatoshisPipe,
|
||||||
|
BlockViewComponent,
|
||||||
|
MempoolBlockViewComponent,
|
||||||
MempoolBlockOverviewComponent,
|
MempoolBlockOverviewComponent,
|
||||||
ClockchainComponent,
|
ClockchainComponent,
|
||||||
ClockComponent,
|
ClockComponent,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user