New base code for mempool blockchain explorerer

This commit is contained in:
Simon Lindh
2020-02-16 22:15:07 +07:00
committed by wiz
parent ca40fc7045
commit ac95c09ea6
204 changed files with 6959 additions and 14341 deletions

View File

@@ -0,0 +1,34 @@
<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 explorer and mempool visualizer.</p>
<p>Created by <a href="https://twitter.com/softbtc">@softbtc</a>
<br />Hosted by <a href="https://twitter.com/wiz">@wiz</a>
<br />Designed by <a href="https://instagram.com/markjborg">@markjborg</a>
<h2>Fee API</h2>
<div class="col-4 mx-auto">
<input class="form-control" type="text" value="https://mempool.space/api/v1/fees/recommended" readonly>
</div>
<br />
<h1>Donate</h1>
<img src="./assets/btc-qr-code-segwit.png" width="200" height="200" />
<br />
bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t
<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>

View File

@@ -0,0 +1,19 @@
import { Component, OnInit } from '@angular/core';
import { WebsocketService } from '../../services/websocket.service';
@Component({
selector: 'app-about',
templateUrl: './about.component.html',
styleUrls: ['./about.component.scss']
})
export class AboutComponent implements OnInit {
constructor(
private websocketService: WebsocketService,
) { }
ngOnInit() {
this.websocketService.want([]);
}
}

View File

@@ -0,0 +1 @@
<span *ngIf="multisig" class="badge badge-pill badge-warning">multisig {{ multisigM }} of {{ multisigN }}</span>

View File

@@ -0,0 +1,3 @@
.badge {
margin-right: 2px;
}

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AddressLabelsComponent } from './address-labels.component';
describe('AddressLabelsComponent', () => {
let component: AddressLabelsComponent;
let fixture: ComponentFixture<AddressLabelsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AddressLabelsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AddressLabelsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,60 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { Vin, Vout } from '../../interfaces/electrs.interface';
@Component({
selector: 'app-address-labels',
templateUrl: './address-labels.component.html',
styleUrls: ['./address-labels.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressLabelsComponent implements OnInit {
@Input() vin: Vin;
@Input() vout: Vout;
multisig = false;
multisigM: number;
multisigN: number;
constructor() { }
ngOnInit() {
if (this.vin) {
this.handleVin();
} else if (this.vout) {
this.handleVout();
}
}
handleVin() {
if (this.vin.inner_witnessscript_asm && this.vin.inner_witnessscript_asm.indexOf('OP_CHECKMULTISIG') > -1) {
const matches = this.getMatches(this.vin.inner_witnessscript_asm, /OP_PUSHNUM_([0-9])/g, 1);
this.multisig = true;
this.multisigM = matches[0];
this.multisigN = matches[1];
}
if (this.vin.inner_redeemscript_asm && this.vin.inner_redeemscript_asm.indexOf('OP_CHECKMULTISIG') > -1) {
const matches = this.getMatches(this.vin.inner_redeemscript_asm, /OP_PUSHNUM_([0-9])/g, 1);
this.multisig = true;
this.multisigM = matches[0];
this.multisigN = matches[1];
}
}
handleVout() {
}
getMatches(str: string, regex: RegExp, index: number) {
if (!index) {
index = 1;
}
const matches = [];
let match;
while (match = regex.exec(str)) {
matches.push(match[index]);
}
return matches;
}
}

View File

@@ -0,0 +1,100 @@
<div class="container">
<h1 style="float: left;">Address</h1>
<a [routerLink]="['/address/', addressString]" style="line-height: 55px; margin-left: 10px;">{{ addressString }}</a>
<app-clipboard [text]="addressString"></app-clipboard>
<br>
<div class="clearfix"></div>
<ng-template [ngIf]="!isLoadingAddress && !error">
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Number of transactions</td>
<td>{{ address.chain_stats.tx_count + address.mempool_stats.tx_count }}</td>
</tr>
<tr>
<td>Total received</td>
<td>{{ (address.chain_stats.funded_txo_sum + address.mempool_stats.funded_txo_sum) / 100000000 | number: '1.2-2' }} BTC</td>
</tr>
<tr>
<td>Total sent</td>
<td>{{ (address.chain_stats.spent_txo_sum + address.mempool_stats.spent_txo_sum) / 100000000 | number: '1.2-2' }} BTC</td>
</tr>
</tbody>
</table>
</div>
<div class="col text-right">
<div class="qr-wrapper">
<app-qrcode [data]="address.address"></app-qrcode>
<!--qrcode id="qrCode" [qrdata]="address.address" [size]="128" [level]="'M'"></qrcode>-->
</div>
</div>
</div>
</div>
<br>
<h2><ng-template [ngIf]="transactions?.length">{{ transactions?.length || '?' }} of </ng-template>{{ address.chain_stats.tx_count + address.mempool_stats.tx_count }} transactions</h2>
<app-transactions-list [transactions]="transactions" [showConfirmations]="true"></app-transactions-list>
<div class="text-center">
<ng-template [ngIf]="isLoadingTransactions">
<div class="spinner-border"></div>
<br><br>
</ng-template>
<button *ngIf="transactions?.length && transactions?.length !== (address.chain_stats.tx_count + address.mempool_stats.tx_count)" type="button" class="btn btn-primary" (click)="loadMore()">Load more</button>
</div>
</ng-template>
<ng-template [ngIf]="isLoadingAddress && !error">
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
<div class="col">
</div>
</div>
</div>
<br>
<div class="text-center">
<div class="spinner-border"></div>
<br><br>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div class="text-center">
Error loading address data.
<br>
<i>{{ error.error }}</i>
</div>
</ng-template>
</div>
<br>

View File

@@ -0,0 +1,10 @@
.header-bg {
font-size: 14px;
}
.qr-wrapper {
background-color: #FFF;
padding: 10px;
display: inline-block;
margin-right: 25px;
}

View File

@@ -0,0 +1,65 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap } from 'rxjs/operators';
import { Address, Transaction } from '../../interfaces/electrs.interface';
@Component({
selector: 'app-address',
templateUrl: './address.component.html',
styleUrls: ['./address.component.scss']
})
export class AddressComponent implements OnInit {
address: Address;
addressString: string;
isLoadingAddress = true;
transactions: Transaction[];
isLoadingTransactions = true;
error: any;
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
) { }
ngOnInit() {
this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
this.error = undefined;
this.isLoadingAddress = true;
this.isLoadingTransactions = true;
this.transactions = null;
this.addressString = params.get('id') || '';
return this.electrsApiService.getAddress$(this.addressString);
})
)
.subscribe((address) => {
this.address = address;
this.isLoadingAddress = false;
window.scrollTo(0, 0);
this.getAddressTransactions(address.address);
},
(error) => {
console.log(error);
this.error = error;
this.isLoadingAddress = false;
});
}
getAddressTransactions(address: string) {
this.electrsApiService.getAddressTransactions$(address)
.subscribe((transactions: any) => {
this.transactions = transactions;
this.isLoadingTransactions = false;
});
}
loadMore() {
this.isLoadingTransactions = true;
this.electrsApiService.getAddressTransactionsFromHash$(this.address.address, this.transactions[this.transactions.length - 1].txid)
.subscribe((transactions) => {
this.transactions = this.transactions.concat(transactions);
this.isLoadingTransactions = false;
});
}
}

View File

@@ -0,0 +1,6 @@
<ng-container *ngIf="(viewFiat$ | async) && (conversions$ | async) as conversions; else viewFiatVin">
<span>{{ conversions.USD * (satoshis / 100000000) | currency:'USD':'symbol':'1.2-2' }}</span>
</ng-container>
<ng-template #viewFiatVin>
{{ satoshis / 100000000 }} BTC
</ng-template>

View File

@@ -0,0 +1,3 @@
.green-color {
color: #3bcc49;
}

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AmountComponent } from './amount.component';
describe('AmountComponent', () => {
let component: AmountComponent;
let fixture: ComponentFixture<AmountComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AmountComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AmountComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,26 @@
import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Observable } from 'rxjs';
@Component({
selector: 'app-amount',
templateUrl: './amount.component.html',
styleUrls: ['./amount.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AmountComponent implements OnInit {
conversions$: Observable<any>;
viewFiat$: Observable<boolean>;
@Input() satoshis: number;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.viewFiat$ = this.stateService.viewFiat$.asObservable();
this.conversions$ = this.stateService.conversions$.asObservable();
}
}

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@@ -0,0 +1,7 @@
footer {
max-width: 960px;
}
.logo {
height: 40px;
}

View File

@@ -0,0 +1,35 @@
import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'mempoolspace'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('mempoolspace');
});
it('should render title in a h1 tag', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Welcome to mempoolspace!');
});
});

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { WebsocketService } from '../../services/websocket.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
constructor(
public router: Router,
private websocketService: WebsocketService,
) { }
}

View File

@@ -0,0 +1,134 @@
<div class="container">
<app-blockchain></app-blockchain>
<h1>Block <ng-template [ngIf]="blockHeight"><a [routerLink]="['/block/', blockHash]">#{{ blockHeight }}</a></ng-template></h1>
<ng-template [ngIf]="!isLoadingBlock && !error">
<br>
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Timestamp</td>
<td>{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} <i>(<app-time-since [time]="block.timestamp"></app-time-since> ago)</i></td>
</tr>
<tr>
<td>Number of transactions</td>
<td>{{ block.tx_count }}</td>
</tr>
<tr>
<td>Size</td>
<td>{{ block.size | bytes: 2 }}</td>
</tr>
<tr>
<td>Weight</td>
<td>{{ block.weight | wuBytes: 2 }}</td>
</tr>
<tr>
<td>Status</td>
<td><button *ngIf="latestBlock" class="btn btn-sm btn-success">{{ (latestBlock.height - block.height + 1) }} confirmation{{ (latestBlock.height - block.height + 1) === 1 ? '' : 's' }}</button></td>
</tr>
</tbody>
</table>
</div>
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Hash</td>
<td><a [routerLink]="['/block/', block.id]" title="{{ block.id }}" >{{ block.id | shortenString : 32 }}</a></td>
</tr>
<tr>
<td>Previous Block</td>
<td><a [routerLink]="['/block/', block.previousblockhash]" [state]="{ data: { blockHeight: blockHeight - 1 } }" title="{{ block.previousblockhash }}">{{ block.previousblockhash | shortenString : 32 }}</a></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
<h2><ng-template [ngIf]="transactions?.length">{{ transactions?.length || '?' }} of </ng-template>{{ block.tx_count }} transactions</h2>
<br>
<app-transactions-list [transactions]="transactions"></app-transactions-list>
<div class="text-center">
<ng-template [ngIf]="isLoadingTransactions">
<div class="spinner-border"></div>
<br><br>
</ng-template>
<button *ngIf="transactions?.length && transactions?.length !== block.tx_count" type="button" class="btn btn-primary" (click)="loadMore()">Load more</button>
</div>
</ng-template>
<ng-template [ngIf]="isLoadingBlock && !error">
<br>
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
<div class="text-center">
<div class="spinner-border"></div>
<br><br>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div class="text-center">
Error loading block data.
<br>
<i>{{ error.error }}</i>
</div>
</ng-template>
</div>
<br>

View File

@@ -0,0 +1,83 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap } from 'rxjs/operators';
import { Block, Transaction } from '../../interfaces/electrs.interface';
import { of } from 'rxjs';
import { StateService } from '../../services/state.service';
@Component({
selector: 'app-block',
templateUrl: './block.component.html',
styleUrls: ['./block.component.scss']
})
export class BlockComponent implements OnInit {
block: Block;
blockHeight: number;
blockHash: string;
isLoadingBlock = true;
latestBlock: Block;
transactions: Transaction[];
isLoadingTransactions = true;
error: any;
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
private stateService: StateService,
) { }
ngOnInit() {
this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || '';
this.error = undefined;
if (history.state.data && history.state.data.blockHeight) {
this.blockHeight = history.state.data.blockHeight;
}
this.blockHash = blockHash;
if (history.state.data && history.state.data.block) {
return of(history.state.data.block);
} else {
this.isLoadingBlock = true;
return this.electrsApiService.getBlock$(blockHash);
}
})
)
.subscribe((block: Block) => {
this.block = block;
this.blockHeight = block.height;
this.isLoadingBlock = false;
this.getBlockTransactions(block.id);
window.scrollTo(0, 0);
},
(error) => {
this.error = error;
this.isLoadingBlock = false;
});
this.stateService.blocks$
.subscribe((block) => this.latestBlock = block);
}
getBlockTransactions(hash: string) {
this.electrsApiService.getBlockTransactions$(hash)
.subscribe((transactions: any) => {
this.transactions = transactions;
this.isLoadingTransactions = false;
});
}
loadMore() {
this.isLoadingTransactions = true;
this.electrsApiService.getBlockTransactions$(this.block.id, this.transactions.length)
.subscribe((transactions) => {
this.transactions = this.transactions.concat(transactions);
this.isLoadingTransactions = false;
});
}
}

View File

@@ -0,0 +1,20 @@
<div class="blocks-container" *ngIf="blocks.length">
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn" >
<div [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }" class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" [ngStyle]="getStyleForBlock(block)">
<div class="block-height">
<a [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }">#{{ block.height }}</a>
</div>
<div class="block-body">
<div class="fees">
~{{ block.medianFee | ceil }} sat/vB
<br/>
<span class="yellow-color">{{ block.feeRange[0] | ceil }} - {{ block.feeRange[block.feeRange.length - 1] | ceil }} sat/vB</span>
</div>
<div class="block-size">{{ block.size | bytes: 2 }}</div>
<div class="transaction-count">{{ block.tx_count }} transactions</div>
<br /><br />
<div class="time-difference">{{ block.timestamp | timeSince : trigger }} ago</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,96 @@
.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;
}
.block-body {
text-align: center;
}
.time-difference {
position: absolute;
bottom: 10px;
text-align: center;
width: 100%;
font-size: 14px;
}
.fees {
font-size: 10px;
margin-top: 10px;
margin-bottom: 2px;
}
.transaction-count {
font-size: 12px;
}
.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) {
.block-height {
bottom: 125px;
left: inherit;
text-shadow: inherit;
z-index: inherit;
}
}
@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;
}
}
.black-background {
background-color: #11131f;
z-index: 100;
position: relative;
}

View File

@@ -0,0 +1,60 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { Block } from 'src/app/interfaces/electrs.interface';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-blockchain-blocks',
templateUrl: './blockchain-blocks.component.html',
styleUrls: ['./blockchain-blocks.component.scss']
})
export class BlockchainBlocksComponent implements OnInit, OnDestroy {
blocks: Block[] = [];
blocksSubscription: Subscription;
interval: any;
trigger = 0;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.blocksSubscription = this.stateService.blocks$
.subscribe((block) => {
if (this.blocks.some((b) => b.height === block.height)) {
return;
}
this.blocks.unshift(block);
this.blocks = this.blocks.slice(0, 8);
});
this.interval = setInterval(() => this.trigger++, 10 * 1000);
}
ngOnDestroy() {
this.blocksSubscription.unsubscribe();
clearInterval(this.interval);
}
trackByBlocksFn(index: number, item: Block) {
return item.height;
}
getStyleForBlock(block: Block) {
const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100;
if (window.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%)`,
};
}
}
}

View File

@@ -0,0 +1,20 @@
<div *ngIf="isLoading" class="text-center">
<h3>Loading blocks...</h3>
<br>
<div class="spinner-border text-light"></div>
</div>
<div *ngIf="!isLoading && 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">
<app-mempool-blocks></app-mempool-blocks>
<app-blockchain-blocks></app-blockchain-blocks>
<div id="divider" *ngIf="!isLoading"></div>
</div>
</div>

View File

@@ -0,0 +1,50 @@
#divider {
width: 3px;
height: 200px;
left: 0;
top: -50px;
background-image: url('/assets/divider-new.png');
background-repeat: repeat-y;
position: absolute;
margin-bottom: 120px;
}
#divider > img {
position: absolute;
left: -100px;
top: -28px;
}
.blockchain-wrapper {
overflow: hidden;
height: 250px;
}
.position-container {
position: absolute;
left: 50%;
/* top: calc(50% - 60px); */
top: 180px;
}
@media (max-width: 767.98px) {
#divider {
top: -50px;
height: 1300px;
}
.position-container {
top: 100px;
}
}
@media (min-width: 1920px) {
.position-container {
transform: scale(1.3);
}
}
.black-background {
background-color: #11131f;
z-index: 100;
position: relative;
}

View File

@@ -0,0 +1,86 @@
import { Component, OnInit, OnDestroy, Renderer2 } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
import { WebsocketService } from 'src/app/services/websocket.service';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-blockchain',
templateUrl: './blockchain.component.html',
styleUrls: ['./blockchain.component.scss']
})
export class BlockchainComponent implements OnInit, OnDestroy {
txTrackingSubscription: Subscription;
blocksSubscription: Subscription;
txTrackingLoading = false;
txShowTxNotFound = false;
isLoading = true;
constructor(
private route: ActivatedRoute,
private websocketService: WebsocketService,
private stateService: StateService,
) {}
ngOnInit() {
/*
this.apiService.webSocketWant(['stats', 'blocks', 'mempool-blocks']);
this.txTrackingSubscription = this.memPoolService.txTracking$
.subscribe((response: ITxTracking) => {
this.txTrackingLoading = false;
this.txShowTxNotFound = response.notFound;
if (this.txShowTxNotFound) {
setTimeout(() => { this.txShowTxNotFound = false; }, 2000);
}
});
*/
/*
this.route.paramMap
.subscribe((params: ParamMap) => {
if (this.memPoolService.txTracking$.value.enabled) {
return;
}
const txId: string | null = params.get('id');
if (!txId) {
return;
}
this.txTrackingLoading = true;
this.apiService.webSocketStartTrackTx(txId);
});
*/
/*
this.memPoolService.txIdSearch$
.subscribe((txId) => {
if (txId) {
if (this.memPoolService.txTracking$.value.enabled
&& this.memPoolService.txTracking$.value.tx
&& this.memPoolService.txTracking$.value.tx.txid === txId) {
return;
}
console.log('enabling tracking loading from idSearch!');
this.txTrackingLoading = true;
this.websocketService.startTrackTx(txId);
}
});
*/
this.blocksSubscription = this.stateService.blocks$
.pipe(
take(1)
)
.subscribe((block) => this.isLoading = false);
}
ngOnDestroy() {
this.blocksSubscription.unsubscribe();
// this.txTrackingSubscription.unsubscribe();
}
}

View File

@@ -0,0 +1,5 @@
<span #buttonWrapper [attr.data-tlite]="'Copied!'">
<button #btn class="btn btn-sm btn-link pt-0" style="line-height: 1;" [attr.data-clipboard-text]="text">
<img src="./assets/clippy.svg" width="13">
</button>
</span>

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ClipboardComponent } from './clipboard.component';
describe('ClipboardComponent', () => {
let component: ClipboardComponent;
let fixture: ComponentFixture<ClipboardComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ ClipboardComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ClipboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,33 @@
import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, Input } from '@angular/core';
import * as ClipboardJS from 'clipboard';
import * as tlite from 'tlite';
@Component({
selector: 'app-clipboard',
templateUrl: './clipboard.component.html',
styleUrls: ['./clipboard.component.scss']
})
export class ClipboardComponent implements AfterViewInit {
@ViewChild('btn') btn: ElementRef;
@ViewChild('buttonWrapper') buttonWrapper: ElementRef;
@Input() text: string;
clipboard: any;
constructor() { }
ngAfterViewInit() {
this.clipboard = new ClipboardJS(this.btn.nativeElement);
this.clipboard.on('success', (e) => {
tlite.show(this.buttonWrapper.nativeElement);
setTimeout(() => {
tlite.hide(this.buttonWrapper.nativeElement);
}, 1000);
});
}
onDestroy() {
this.clipboard.destroy();
}
}

View File

@@ -0,0 +1,43 @@
<table class="table table-borderless">
<thead>
<th style="width: 120px;">Height</th>
<th class="d-none d-md-block" style="width: 300px;">Timestamp</th>
<th style="width: 200px;">Mined</th>
<th style="width: 150px;">Transactions</th>
<th style="width: 175px;">Size</th>
<th class="d-none d-md-block">Filled</th>
</thead>
<tbody>
<tr *ngFor="let block of blocks; let i= index; trackBy: trackByBlock">
<td><a [routerLink]="['./block', block.id]" [state]="{ data: { block: block } }">#{{ block.height }}</a></td>
<td class="d-none d-md-block">{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}</td>
<td>{{ block.timestamp | timeSince : trigger }} ago</td>
<td>{{ block.tx_count }}</td>
<td>{{ block.size | bytes: 2 }}</td>
<td class="d-none d-md-block">
<div class="progress position-relative">
<div class="progress-bar bg-success" role="progressbar" [ngStyle]="{'width': (block.weight / 4000000)*100 + '%' }"></div>
</div>
</td>
</tr>
<ng-template [ngIf]="isLoading">
<tr *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
</ng-template>
</tbody>
</table>
<div class="text-center">
<ng-template [ngIf]="isLoading">
<div class="spinner-border"></div>
<br><br>
</ng-template>
<br>
<button *ngIf="blocks.length" [disabled]="isLoading" type="button" class="btn btn-primary" (click)="loadMore()">Load more</button>
</div>

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LatestBlocksComponent } from './latest-blocks.component';
describe('LatestBlocksComponent', () => {
let component: LatestBlocksComponent;
let fixture: ComponentFixture<LatestBlocksComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ LatestBlocksComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LatestBlocksComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,78 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { StateService } from '../../services/state.service';
import { Block } from '../../interfaces/electrs.interface';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-latest-blocks',
templateUrl: './latest-blocks.component.html',
styleUrls: ['./latest-blocks.component.scss'],
})
export class LatestBlocksComponent implements OnInit, OnDestroy {
blocks: any[] = [];
blockSubscription: Subscription;
isLoading = true;
interval: any;
trigger = 0;
constructor(
private electrsApiService: ElectrsApiService,
private stateService: StateService,
) { }
ngOnInit() {
this.blockSubscription = this.stateService.blocks$
.subscribe((block) => {
if (block === null || !this.blocks.length) {
return;
}
if (block.height === this.blocks[0].height) {
return;
}
// If we are out of sync, reload the blocks instead
if (block.height > this.blocks[0].height + 1) {
this.loadInitialBlocks();
return;
}
if (block.height === this.blocks[0].height) {
return;
}
this.blocks.pop();
this.blocks.unshift(block);
});
this.loadInitialBlocks();
this.interval = window.setInterval(() => this.trigger++, 1000 * 60);
}
ngOnDestroy() {
clearInterval(this.interval);
this.blockSubscription.unsubscribe();
}
loadInitialBlocks() {
this.electrsApiService.listBlocks$()
.subscribe((blocks) => {
this.blocks = blocks;
this.isLoading = false;
});
}
loadMore() {
this.isLoading = true;
this.electrsApiService.listBlocks$(this.blocks[this.blocks.length - 1].height - 1)
.subscribe((blocks) => {
this.blocks = this.blocks.concat(blocks);
this.isLoading = false;
});
}
trackByBlock(index: number, block: Block) {
return block.height;
}
}

View File

@@ -0,0 +1,28 @@
<table class="table table-borderless">
<thead>
<th>Transaction ID</th>
<th style="width: 200px;">Value</th>
<th style="width: 125px;">Size</th>
<th style="width: 150px;">Fee</th>
</thead>
<tbody>
<ng-container *ngIf="(transactions$ | async) as transactions">
<ng-template [ngIf]="!isLoading">
<tr *ngFor="let transaction of transactions">
<td><a [routerLink]="['./tx/', transaction.txid]">{{ transaction.txid }}</a></td>
<td>{{ transaction.value / 100000000 }} BTC</td>
<td>{{ transaction.vsize | vbytes: 2 }}</td>
<td>{{ transaction.fee / transaction.vsize | number : '1.2-2'}} sats/vB</td>
</tr>
</ng-template>
</ng-container>
<ng-template [ngIf]="isLoading">
<tr *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
</ng-template>
</tbody>
</table>

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LatestTransactionsComponent } from './latest-transactions.component';
describe('LatestTransactionsComponent', () => {
let component: LatestTransactionsComponent;
let fixture: ComponentFixture<LatestTransactionsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ LatestTransactionsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LatestTransactionsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,31 @@
import { Component, OnInit } from '@angular/core';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { Observable, timer } from 'rxjs';
import { Recent } from '../../interfaces/electrs.interface';
import { flatMap, tap } from 'rxjs/operators';
@Component({
selector: 'app-latest-transactions',
templateUrl: './latest-transactions.component.html',
styleUrls: ['./latest-transactions.component.scss']
})
export class LatestTransactionsComponent implements OnInit {
transactions$: Observable<Recent[]>;
isLoading = true;
constructor(
private electrsApiService: ElectrsApiService,
) { }
ngOnInit() {
this.transactions$ = timer(0, 10000)
.pipe(
flatMap(() => {
return this.electrsApiService.getRecentTransaction$()
.pipe(
tap(() => this.isLoading = false)
);
})
);
}
}

View File

@@ -0,0 +1,31 @@
<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" [routerLinkActiveOptions]="{exact: true}">
<a class="nav-link" routerLink="/" (click)="collapse()">Explorer</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="/tv" (click)="collapse()">TV view &nbsp;<img src="./assets/expand.png" width="15"/></a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/about" (click)="collapse()">About</a>
</li>
</ul>
<app-search-form location="top"></app-search-form>
</div>
</nav>
</header>
<br />
<router-outlet></router-outlet>

View 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;
}

View File

@@ -0,0 +1,27 @@
import { Component, OnInit } from '@angular/core';
import { StateService } from '../../services/state.service';
@Component({
selector: 'app-master-page',
templateUrl: './master-page.component.html',
styleUrls: ['./master-page.component.scss']
})
export class MasterPageComponent implements OnInit {
navCollapsed = false;
isOffline = false;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.stateService.isOffline$
.subscribe((state) => {
this.isOffline = state;
});
}
collapse(): void {
this.navCollapsed = !this.navCollapsed;
}
}

View File

@@ -0,0 +1,20 @@
<div class="mempool-blocks-container">
<div *ngFor="let projectedBlock of mempoolBlocks; let i = index; trackBy: trackByFn">
<div class="bitcoin-block text-center mempool-block" id="mempool-block-{{ i }}" [ngStyle]="getStyleForMempoolBlockAtIndex(i)">
<div class="block-body" *ngIf="mempoolBlocks?.length">
<div class="fees">
~{{ projectedBlock.medianFee | ceil }} sat/vB
<br/>
<span class="yellow-color">{{ projectedBlock.feeRange[0] | ceil }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | 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 && mempoolBlocks?.length >= 4 && (projectedBlock.blockVSize / 1000000 | ceil) > 1">
<div class="time-difference">+{{ projectedBlock.blockVSize / 1000000 | ceil }} blocks</div>
</ng-template>
</div>
<span class="animated-border"></span>
</div>
</div>
</div>

View File

@@ -0,0 +1,102 @@
.bitcoin-block {
width: 125px;
height: 125px;
}
.block-size {
font-size: 18px;
font-weight: bold;
}
.mempool-blocks-container {
position: absolute;
top: 0px;
right: 0px;
left: 0px;
animation: opacityPulse 2s ease-out;
animation-iteration-count: infinite;
opacity: 1;
}
.mempool-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;
}
.fees {
font-size: 10px;
margin-top: 10px;
margin-bottom: 2px;
}
.transaction-count {
font-size: 12px;
}
@media (max-width: 767.98px) {
.mempool-blocks-container {
position: absolute;
left: -165px;
top: -40px;
}
}
@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;
}
.mempool-block.bitcoin-block::after {
background-color: #403834;
}
.mempool-block.bitcoin-block::before {
background-color: #2d2825;
}
}
.black-background {
background-color: #11131f;
z-index: 100;
position: relative;
}

View File

@@ -0,0 +1,83 @@
import { Component, OnInit, OnDestroy, Input, EventEmitter, Output } from '@angular/core';
import { Subscription } from 'rxjs';
import { MempoolBlock } from 'src/app/interfaces/websocket.interface';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-mempool-blocks',
templateUrl: './mempool-blocks.component.html',
styleUrls: ['./mempool-blocks.component.scss']
})
export class MempoolBlocksComponent implements OnInit, OnDestroy {
mempoolBlocks: MempoolBlock[];
mempoolBlocksSubscription: Subscription;
blockWidth = 125;
blockMarginLeft = 20;
@Input() txFeePerVSize: number;
@Output() rightPosition: EventEmitter<number> = new EventEmitter<number>(true);
@Output() blockDepth: EventEmitter<number> = new EventEmitter<number>(true);
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$
.subscribe((blocks) => {
this.mempoolBlocks = blocks;
this.calculateTransactionPosition();
});
}
ngOnDestroy() {
this.mempoolBlocksSubscription.unsubscribe();
}
trackByFn(index: number) {
return index;
}
getStyleForMempoolBlockAtIndex(index: number) {
const greenBackgroundHeight = 100 - this.mempoolBlocks[index].blockVSize / 1000000 * 100;
return {
'right': 40 + index * 155 + 'px',
'background': `repeating-linear-gradient(to right, #554b45, #554b45 ${greenBackgroundHeight}%,
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
};
}
calculateTransactionPosition() {
if (!this.txFeePerVSize) {
return;
}
for (const block of this.mempoolBlocks) {
for (let i = 0; i < block.feeRange.length - 1; i++) {
if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) {
const txInBlockIndex = this.mempoolBlocks.indexOf(block);
const feeRangeIndex = block.feeRange.findIndex((val, index) => this.txFeePerVSize < block.feeRange[index + 1]);
const feeRangeChunkSize = 1 / (block.feeRange.length - 1);
const txFee = this.txFeePerVSize - block.feeRange[i];
const max = block.feeRange[i + 1] - block.feeRange[i];
const blockLocation = txFee / max;
const chunkPositionOffset = blockLocation * feeRangeChunkSize;
const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset;
const blockedFilledPercentage = (block.blockVSize > 1000000 ? 1000000 : block.blockVSize) / 1000000;
const arrowRightPosition = txInBlockIndex * (this.blockMarginLeft + this.blockWidth)
+ ((1 - feePosition) * blockedFilledPercentage * this.blockWidth);
this.rightPosition.next(arrowRightPosition);
this.blockDepth.next(txInBlockIndex);
break;
}
}
}
}
}

View File

@@ -0,0 +1 @@
<canvas #canvas></canvas>

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { QrcodeComponent } from './qrcode.component';
describe('QrcodeComponent', () => {
let component: QrcodeComponent;
let fixture: ComponentFixture<QrcodeComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ QrcodeComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(QrcodeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,43 @@
import { Component, Input, AfterViewInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
import * as QRCode from 'qrcode/build/qrcode.js';
@Component({
selector: 'app-qrcode',
templateUrl: './qrcode.component.html',
styleUrls: ['./qrcode.component.scss']
})
export class QrcodeComponent implements AfterViewInit, OnDestroy {
@Input() data: string;
@ViewChild('canvas') canvas: ElementRef;
qrcodeObject: any;
constructor() { }
ngAfterViewInit() {
const opts = {
errorCorrectionLevel: 'H',
margin: 0,
color: {
dark: '#000',
light: '#fff'
},
width: 125,
height: 125,
};
if (!this.data) {
return;
}
QRCode.toCanvas(this.canvas.nativeElement, this.data.toUpperCase(), opts, (error: any) => {
if (error) {
console.error(error);
}
});
}
ngOnDestroy() {
}
}

View File

@@ -0,0 +1,29 @@
<ng-template [ngIf]="location === 'start'" [ngIfElse]="top">
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<div class="form-row">
<div class="col-12 col-md-10 mb-2 mb-md-0">
<input formControlName="searchText" type="text" class="form-control form-control-lg" [placeholder]="searchBoxPlaceholderText">
</div>
<div class="col-12 col-md-2">
<button type="submit" class="btn btn-block btn-lg btn-primary">{{ searchButtonText }}</button>
</div>
</div>
</form>
</ng-template>
<ng-template #top>
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<div class="form-row">
<div style="width: 350px;" class="mr-2">
<input formControlName="searchText" type="text" class="form-control" [placeholder]="searchBoxPlaceholderText">
</div>
<div>
<button type="submit" class="btn btn-block btn-primary">{{ searchButtonText }}</button>
</div>
</div>
</form>
</ng-template>

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchFormComponent } from './search-form.component';
describe('SearchFormComponent', () => {
let component: SearchFormComponent;
let fixture: ComponentFixture<SearchFormComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SearchFormComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,44 @@
import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
@Component({
selector: 'app-search-form',
templateUrl: './search-form.component.html',
styleUrls: ['./search-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SearchFormComponent implements OnInit {
@Input() location: string;
searchForm: FormGroup;
searchButtonText = 'Search';
searchBoxPlaceholderText = 'Transaction, address, block hash...';
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,87})$/;
constructor(
private formBuilder: FormBuilder,
private router: Router,
) { }
ngOnInit() {
this.searchForm = this.formBuilder.group({
searchText: ['', Validators.required],
});
}
search() {
const searchText = this.searchForm.value.searchText.trim();
if (searchText) {
if (this.regexAddress.test(searchText)) {
this.router.navigate(['/address/', searchText]);
} else {
this.router.navigate(['/tx/', searchText]);
}
this.searchForm.setValue({
searchText: '',
});
}
}
}

View File

@@ -0,0 +1,20 @@
<app-blockchain></app-blockchain>
<div class="box">
<div class="container">
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'blocks'" href="#" (click)="view = 'blocks'">Blocks</a>
</li>
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'transactions'" href="#" (click)="view = 'transactions'">Transactions</a>
</li>
</ul>
<app-latest-blocks *ngIf="view === 'blocks'; else latestTransactions"></app-latest-blocks>
<ng-template #latestTransactions>
<app-latest-transactions></app-latest-transactions>
</ng-template>
</div>
</div>

View File

@@ -0,0 +1,3 @@
.search-container {
padding-top: 50px;
}

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { StartComponent } from './start.component';
describe('StartComponent', () => {
let component: StartComponent;
let fixture: ComponentFixture<StartComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ StartComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(StartComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-start',
templateUrl: './start.component.html',
styleUrls: ['./start.component.scss']
})
export class StartComponent {
view: 'blocks' | 'transactions' = 'blocks';
}

View 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);
}
}
}

View 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 instanceof Chartist.Bar) {
tooltipSelector = 'ct-bar';
} else if (chart instanceof Chartist.Pie) {
// 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, $point.parentNode.getAttribute('class'));
}
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;
}

View File

@@ -0,0 +1,88 @@
<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'"> 1Y
</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>
</div>

View 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;
}

View File

@@ -0,0 +1,227 @@
import { Component, OnInit, LOCALE_ID, Inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { formatDate } from '@angular/common';
import { FormGroup, FormBuilder } from '@angular/forms';
import { of, merge} from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { VbytesPipe } from '../../pipes/bytes-pipe/vbytes.pipe';
import { MempoolStats } from '../../interfaces/node-api.interface';
import { WebsocketService } from '../../services/websocket.service';
import { ApiService } from '../../services/api.service';
import * as Chartist from 'chartist';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-statistics',
templateUrl: './statistics.component.html',
styleUrls: ['./statistics.component.scss']
})
export class StatisticsComponent implements OnInit {
loading = true;
spinnerLoading = false;
mempoolStats: MempoolStats[] = [];
mempoolVsizeFeesData: any;
mempoolUnconfirmedTransactionsData: any;
mempoolTransactionsWeightPerSecondData: any;
mempoolVsizeFeesOptions: any;
transactionsWeightPerSecondOptions: any;
radioGroupForm: FormGroup;
constructor(
@Inject(LOCALE_ID) private locale: string,
private vbytesPipe: VbytesPipe,
private formBuilder: FormBuilder,
private route: ActivatedRoute,
private websocketService: WebsocketService,
private apiService: ApiService,
private stateService: StateService,
) {
this.radioGroupForm = this.formBuilder.group({
dateSpan: '2h'
});
}
ngOnInit() {
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.vbytesPipe.transform(value, 2);
},
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.route
.fragment
.subscribe((fragment) => {
if (['2h', '24h', '1w', '1m', '3m', '6m'].indexOf(fragment) > -1) {
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
}
});
merge(
of(''),
this.radioGroupForm.controls.dateSpan.valueChanges
.pipe(
tap(() => {
this.mempoolStats = [];
})
)
)
.pipe(
switchMap(() => {
this.spinnerLoading = true;
if (this.radioGroupForm.controls.dateSpan.value === '2h') {
this.websocketService.want(['live-2h-chart']);
return this.apiService.list2HStatistics$();
}
this.websocketService.want([]);
if (this.radioGroupForm.controls.dateSpan.value === '24h') {
return this.apiService.list24HStatistics$();
}
if (this.radioGroupForm.controls.dateSpan.value === '1w') {
return this.apiService.list1WStatistics$();
}
if (this.radioGroupForm.controls.dateSpan.value === '1m') {
return this.apiService.list1MStatistics$();
}
if (this.radioGroupForm.controls.dateSpan.value === '3m') {
return this.apiService.list3MStatistics$();
}
return this.apiService.list6MStatistics$();
})
)
.subscribe((mempoolStats: any) => {
this.mempoolStats = mempoolStats;
this.handleNewMempoolData(this.mempoolStats.concat([]));
this.loading = false;
this.spinnerLoading = false;
});
this.stateService.live2Chart$
.subscribe((mempoolStats) => {
this.mempoolStats.unshift(mempoolStats);
this.mempoolStats = this.mempoolStats.slice(0, this.mempoolStats.length - 1);
this.handleNewMempoolData(this.mempoolStats.concat([]));
});
}
handleNewMempoolData(mempoolStats: MempoolStats[]) {
mempoolStats.reverse();
const labels = mempoolStats.map(stats => stats.added);
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
};
}
generateArray(mempoolStats: MempoolStats[]) {
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;
}
}

View File

@@ -0,0 +1,25 @@
<div id="tv-wrapper">
<div *ngIf="loading" class="text-center">
<div class="spinner-border text-light"></div>
</div>
<div class="chart-holder" *ngIf="mempoolVsizeFeesData">
<app-chartist
[data]="mempoolVsizeFeesData"
[type]="'Line'"
[options]="mempoolVsizeFeesOptions">
</app-chartist>
</div>
<div class="text-center" class="blockchain-wrapper">
<div class="position-container">
<app-mempool-blocks></app-mempool-blocks>
<app-blockchain-blocks></app-blockchain-blocks>
<div id="divider"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,49 @@
#tv-wrapper {
height: 100%;
margin: 10px;
margin-top: 20px;
}
.blockchain-wrapper {
overflow: hidden;
}
.position-container {
position: absolute;
left: 50%;
bottom: 150px;
}
.chart-holder {
height: calc(100% - 220px);
}
#divider {
width: 3px;
height: 175px;
left: 0;
top: -40px;
background-image: url('/assets/divider-new.png');
background-repeat: repeat-y;
position: absolute;
}
#divider > img {
position: absolute;
left: -100px;
top: -28px;
}
@media (min-width: 1920px) {
.position-container {
transform: scale(1.3);
bottom: 190px;
}
.chart-holder {
height: calc(100% - 280px);
}
}
:host ::ng-deep .ct-legend {
top: 25px;
}

View File

@@ -0,0 +1,135 @@
import { Component, OnInit, LOCALE_ID, Inject, Renderer2 } from '@angular/core';
import { formatDate } from '@angular/common';
import { VbytesPipe } from '../../pipes/bytes-pipe/vbytes.pipe';
import * as Chartist from 'chartist';
import { WebsocketService } from 'src/app/services/websocket.service';
import { MempoolStats } from '../../interfaces/node-api.interface';
import { StateService } from 'src/app/services/state.service';
import { ApiService } from 'src/app/services/api.service';
@Component({
selector: 'app-television',
templateUrl: './television.component.html',
styleUrls: ['./television.component.scss']
})
export class TelevisionComponent implements OnInit {
loading = true;
mempoolStats: MempoolStats[] = [];
mempoolVsizeFeesData: any;
mempoolVsizeFeesOptions: any;
constructor(
private websocketService: WebsocketService,
@Inject(LOCALE_ID) private locale: string,
private vbytesPipe: VbytesPipe,
private renderer: Renderer2,
private apiService: ApiService,
private stateService: StateService,
) { }
ngOnInit() {
this.websocketService.want(['live-2h-chart']);
this.renderer.addClass(document.body, 'disable-scroll');
const labelInterpolationFnc = (value: any, index: any) => {
return index % 6 === 0 ? formatDate(value, 'HH:mm', this.locale) : 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.vbytesPipe.transform(value, 2);
},
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].map((sats, i, arr) => {
if (sats === 400) {
return '350+';
}
if (i === 0) {
return '1 sat/vbyte';
}
return arr[i - 1] + ' - ' + sats;
})
})
]
};
this.apiService.list2HStatistics$()
.subscribe((mempoolStats) => {
this.mempoolStats = mempoolStats;
this.handleNewMempoolData(this.mempoolStats.concat([]));
this.loading = false;
});
this.stateService.live2Chart$
.subscribe((mempoolStats) => {
this.mempoolStats.unshift(mempoolStats);
this.mempoolStats = this.mempoolStats.slice(0, this.mempoolStats.length - 1);
this.handleNewMempoolData(this.mempoolStats.concat([]));
});
}
handleNewMempoolData(mempoolStats: MempoolStats[]) {
mempoolStats.reverse();
const labels = mempoolStats.map(stats => stats.added);
const finalArrayVbyte = this.generateArray(mempoolStats);
// Remove the 0-1 fee vbyte since it's practially empty
finalArrayVbyte.shift();
this.mempoolVsizeFeesData = {
labels: labels,
series: finalArrayVbyte
};
}
generateArray(mempoolStats: MempoolStats[]) {
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;
}
}

View File

@@ -0,0 +1,29 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core';
@Component({
selector: 'app-time-since',
template: `{{ time | timeSince : trigger }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimeSinceComponent implements OnInit, OnDestroy {
interval: number;
trigger = 0;
@Input() time: number;
constructor(
private ref: ChangeDetectorRef
) { }
ngOnInit() {
this.interval = window.setInterval(() => {
this.trigger++;
this.ref.markForCheck();
}, 1000 * 60);
}
ngOnDestroy() {
clearInterval(this.interval);
}
}

View File

@@ -0,0 +1,137 @@
<div class="container">
<app-blockchain></app-blockchain>
<div class="clearfix"></div>
<h1>Transaction</h1>
<ng-template [ngIf]="!isLoadingTx && !error">
<ng-template [ngIf]="tx.status.confirmed" [ngIfElse]="unconfirmedTemplate">
<div class="box">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Status</td>
<td class="adjust-btn-padding">
<ng-template [ngIf]="latestBlock">
<button type="button" class="btn btn-sm btn-success">{{ latestBlock.height - tx.status.block_height + 1 }} confirmation<ng-container *ngIf="latestBlock.height - tx.status.block_height + 1 > 1">s</ng-container></button>
</ng-template>
</td>
</tr>
<tr>
<td>Included in block</td>
<td><a [routerLink]="['/block/', tx.status.block_hash]" [state]="{ data: { blockHeight: tx.status.block_height } }">#{{ tx.status.block_height }}</a> at {{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }} <i>(<app-time-since [time]="tx.status.block_time"></app-time-since> ago)</i></td>
</tr>
<tr>
<td>Fees</td>
<td>{{ tx.fee | number }} sats <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * tx.fee / 100000000 | currency:'USD':'symbol':'1.2-2' }}</span>)</span></td>
</tr>
<tr>
<td>Fees per vByte</td>
<td>{{ tx.fee / (tx.weight / 4) | number : '1.2-2' }} sat/vB</td>
</tr>
</tbody>
</table>
</div>
<br>
</ng-template>
<ng-template #unconfirmedTemplate>
<div class="box">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Transaction</td>
<td>
<a [routerLink]="['/tx/', txId]">{{ txId | shortenString }}</a>
<app-clipboard [text]="txId"></app-clipboard>
</td>
<td class="adjust-btn-padding">
<button type="button" class="btn btn-sm btn-danger">Unconfirmed</button>
</td>
</tr>
<tr>
<td>Fees</td>
<td>{{ tx.fee | number }} sats <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * tx.fee / 100000000 | currency:'USD':'symbol':'1.2-2' }}</span>)</span></td>
<td>{{ tx.fee / (tx.weight / 4) | number : '1.2-2' }} sat/vB</td>
</tr>
</tbody>
</table>
</div>
<br>
<!--
<div>
<app-mempool-blocks style="right: 20px; position: relative;" *ngIf="!tx.status.confirmed" [txFeePerVSize]="tx.fee / (tx.weight / 4)" (rightPosition)="rightPosition = $event" (blockDepth)="blockDepth = $event"></app-mempool-blocks>
</div>
-->
<div class="clearfix"></div>
</ng-template>
<h2>Inputs & Outputs</h2>
<app-transactions-list [transactions]="[tx]" [transactionPage]="true"></app-transactions-list>
<h2>Details</h2>
<div class="box">
<div class="row">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Size</td>
<td>{{ tx.size | bytes: 2 }}</td>
</tr>
<tr>
<td>Weight</td>
<td>{{ tx.weight | wuBytes: 2 }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</ng-template>
<ng-template [ngIf]="isLoadingTx && !error">
<div class="box">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
<br>
<div class="text-center">
<div class="spinner-border"></div>
<br><br>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div class="text-center">
Error loading transaction data.
<br>
<i>{{ error.error }}</i>
</div>
</ng-template>
</div>
<br>

View File

@@ -0,0 +1,12 @@
.adjust-btn-padding {
padding: 0.55rem;
}
#arrow {
position: absolute;
bottom: -24px;
width: 40px;
right: -1px;
width: 40px;
}

View File

@@ -0,0 +1,76 @@
import { Component, OnInit } from '@angular/core';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { switchMap } from 'rxjs/operators';
import { Transaction, Block } from '../../interfaces/electrs.interface';
import { of } from 'rxjs';
import { StateService } from '../../services/state.service';
import { WebsocketService } from '../../services/websocket.service';
@Component({
selector: 'app-transaction',
templateUrl: './transaction.component.html',
styleUrls: ['./transaction.component.scss']
})
export class TransactionComponent implements OnInit {
tx: Transaction;
txId: string;
isLoadingTx = true;
conversions: any;
error: any = undefined;
latestBlock: Block;
rightPosition = 0;
blockDepth = 0;
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
private stateService: StateService,
private websocketService: WebsocketService,
) { }
ngOnInit() {
this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
this.txId = params.get('id') || '';
this.error = undefined;
this.isLoadingTx = true;
if (history.state.data) {
return of(history.state.data);
} else {
return this.electrsApiService.getTransaction$(this.txId);
}
})
)
.subscribe((tx: Transaction) => {
this.tx = tx;
this.isLoadingTx = false;
window.scrollTo(0, 0);
if (!tx.status.confirmed) {
this.websocketService.startTrackTx(tx.txid);
}
},
(error) => {
this.error = error;
this.isLoadingTx = false;
});
this.stateService.conversions$
.subscribe((conversions) => this.conversions = conversions);
this.stateService.blocks$
.subscribe((block) => this.latestBlock = block);
this.stateService.txConfirmed
.subscribe((block) => {
this.tx.status = {
confirmed: true,
block_height: block.height,
block_hash: block.id,
block_time: block.timestamp,
};
});
}
}

View File

@@ -0,0 +1,87 @@
<ng-container *ngFor="let tx of transactions; let i = index; trackBy: trackByFn">
<div *ngIf="!transactionPage" class="header-bg box" style="padding: 10px; margin-bottom: 10px;">
<a [routerLink]="['/tx/', tx.txid]" [state]="{ data: tx }">{{ tx.txid }}</a>
</div>
<div class="header-bg box">
<div class="row">
<div class="col">
<table class="table table-borderless smaller-text table-xs" style="margin: 0;">
<tbody>
<tr *ngFor="let vin of tx.vin">
<td class="arrow-td">
<a [routerLink]="['/tx/', vin.txid]">
<i class="arrow green"></i>
</a>
</td>
<td>
<div>
<ng-template [ngIf]="vin.is_coinbase" [ngIfElse]="regularVin">
Coinbase
</ng-template>
<ng-template #regularVin>
<a [routerLink]="['/address/', vin.prevout.scriptpubkey_address]" title="{{ vin.prevout.scriptpubkey_address }}">{{ vin.prevout.scriptpubkey_address | shortenString : 42 }}</a>
<div>
<app-address-labels [vin]="vin"></app-address-labels>
</div>
</ng-template>
</div>
</td>
<td class="text-right nowrap">
<app-amount *ngIf="vin.prevout" [satoshis]="vin.prevout.value"></app-amount>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col">
<table class="table table-borderless smaller-text table-xs" style="margin: 0;">
<tbody>
<tr *ngFor="let vout of tx.vout; let vindex = index;">
<td>
<a *ngIf="vout.scriptpubkey_address; else scriptpubkey_type" [routerLink]="['/address/', vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}">{{ vout.scriptpubkey_address | shortenString : 42 }}</a>
<ng-template #scriptpubkey_type>
OP_RETURN
</ng-template>
<!--
<div>
<app-address-labels [vout]="vout"></app-address-labels>
</div>
-->
</td>
<td class="text-right nowrap">
<app-amount [satoshis]="vout.value"></app-amount>
</td>
<td class="pl-1 arrow-td">
<i *ngIf="!outspends[i]; else outspend" class="arrow grey"></i>
<ng-template #outspend>
<i *ngIf="!outspends[i][vindex] || !outspends[i][vindex].spent; else spent" class="arrow green"></i>
<ng-template #spent>
<a [routerLink]="['/tx/', outspends[i][vindex].txid]"><i class="arrow red"></i></a>
</ng-template>
</ng-template>
</td>
</tr>
<tr>
<td class="text-right" colspan="4">
<span *ngIf="showConfirmations && latestBlock$ | async as latestBlock">
<button *ngIf="tx.status.confirmed; else unconfirmedButton" type="button" class="btn btn-sm btn-success mt-3">{{ latestBlock.height - tx.status.block_height + 1 }} confirmations</button>
<ng-template #unconfirmedButton>
<button type="button" class="btn btn-sm btn-danger mt-3">Unconfirmed</button>
</ng-template>
&nbsp;
</span>
<button type="button" class="btn btn-sm btn-primary mt-3" (click)="switchCurrency()">
<app-amount [satoshis]="getTotalTxOutput(tx)"></app-amount>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
</ng-container>

View File

@@ -0,0 +1,64 @@
.header-bg {
font-size: 14px;
}
.arrow-td {
width: 22px;
}
.arrow {
display: inline-block!important;
position: relative;
width: 14px;
height: 22px;
box-sizing: content-box
}
.arrow:before {
position: absolute;
content: '';
margin: auto;
top: 0;
bottom: 0;
left: 0;
right: calc(-1*30px/3);
width: 0;
height: 0;
border-top: 6.66px solid transparent;
border-bottom: 6.66px solid transparent
}
.arrow:after {
position: absolute;
content: '';
margin: auto;
top: 0;
bottom: 0;
left: 0;
right: calc(30px/6);
width: calc(30px/3);
height: calc(20px/3);
background: rgba(0, 0, 0, 0);
}
.arrow.green:before {
border-left: 10px solid #28a745;
}
.arrow.green:after {
background-color:#28a745;
}
.arrow.red:before {
border-left: 10px solid #dc3545;
}
.arrow.red:after {
background-color:#dc3545;
}
.arrow.grey:before {
border-left: 10px solid #6c757d;
}
.arrow.grey:after {
background-color:#6c757d;
}

View File

@@ -0,0 +1,68 @@
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, ChangeDetectorRef } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Observable, forkJoin } from 'rxjs';
import { Block, Outspend } from '../../interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service';
@Component({
selector: 'app-transactions-list',
templateUrl: './transactions-list.component.html',
styleUrls: ['./transactions-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TransactionsListComponent implements OnInit, OnChanges {
@Input() transactions: any[];
@Input() showConfirmations = false;
@Input() transactionPage = false;
latestBlock$: Observable<Block>;
outspends: Outspend[] = [];
constructor(
private stateService: StateService,
private electrsApiService: ElectrsApiService,
private ref: ChangeDetectorRef,
) { }
ngOnInit() {
this.latestBlock$ = this.stateService.blocks$;
}
ngOnChanges() {
if (!this.transactions || !this.transactions.length) {
return;
}
const observableObject = {};
this.transactions.forEach((tx, i) => {
if (this.outspends[i]) {
return;
}
observableObject[i] = this.electrsApiService.getOutspends$(tx.txid);
});
forkJoin(observableObject)
.subscribe((outspends: any) => {
const newOutspends = [];
for (const i in outspends) {
if (outspends.hasOwnProperty(i)) {
newOutspends.push(outspends[i]);
}
}
this.outspends = this.outspends.concat(newOutspends);
this.ref.markForCheck();
});
}
getTotalTxOutput(tx: any) {
return tx.vout.map((v: any) => v.value || 0).reduce((a: number, b: number) => a + b);
}
switchCurrency() {
const oldvalue = !this.stateService.viewFiat$.value;
this.stateService.viewFiat$.next(oldvalue);
}
trackByFn(index: number) {
return index;
}
}