New base code for mempool blockchain explorerer
This commit is contained in:
34
frontend/src/app/components/about/about.component.html
Normal file
34
frontend/src/app/components/about/about.component.html
Normal 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>
|
||||
19
frontend/src/app/components/about/about.component.ts
Normal file
19
frontend/src/app/components/about/about.component.ts
Normal 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([]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<span *ngIf="multisig" class="badge badge-pill badge-warning">multisig {{ multisigM }} of {{ multisigN }}</span>
|
||||
@@ -0,0 +1,3 @@
|
||||
.badge {
|
||||
margin-right: 2px;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
100
frontend/src/app/components/address/address.component.html
Normal file
100
frontend/src/app/components/address/address.component.html
Normal 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>
|
||||
10
frontend/src/app/components/address/address.component.scss
Normal file
10
frontend/src/app/components/address/address.component.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.header-bg {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
background-color: #FFF;
|
||||
padding: 10px;
|
||||
display: inline-block;
|
||||
margin-right: 25px;
|
||||
}
|
||||
65
frontend/src/app/components/address/address.component.ts
Normal file
65
frontend/src/app/components/address/address.component.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
6
frontend/src/app/components/amount/amount.component.html
Normal file
6
frontend/src/app/components/amount/amount.component.html
Normal 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>
|
||||
3
frontend/src/app/components/amount/amount.component.scss
Normal file
3
frontend/src/app/components/amount/amount.component.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.green-color {
|
||||
color: #3bcc49;
|
||||
}
|
||||
25
frontend/src/app/components/amount/amount.component.spec.ts
Normal file
25
frontend/src/app/components/amount/amount.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
frontend/src/app/components/amount/amount.component.ts
Normal file
26
frontend/src/app/components/amount/amount.component.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
1
frontend/src/app/components/app/app.component.html
Normal file
1
frontend/src/app/components/app/app.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
||||
7
frontend/src/app/components/app/app.component.scss
Normal file
7
frontend/src/app/components/app/app.component.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
footer {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 40px;
|
||||
}
|
||||
35
frontend/src/app/components/app/app.component.spec.ts
Normal file
35
frontend/src/app/components/app/app.component.spec.ts
Normal 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!');
|
||||
});
|
||||
});
|
||||
15
frontend/src/app/components/app/app.component.ts
Normal file
15
frontend/src/app/components/app/app.component.ts
Normal 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,
|
||||
) { }
|
||||
}
|
||||
134
frontend/src/app/components/block/block.component.html
Normal file
134
frontend/src/app/components/block/block.component.html
Normal 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>
|
||||
83
frontend/src/app/components/block/block.component.ts
Normal file
83
frontend/src/app/components/block/block.component.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
33
frontend/src/app/components/clipboard/clipboard.component.ts
Normal file
33
frontend/src/app/components/clipboard/clipboard.component.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 <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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
1
frontend/src/app/components/qrcode/qrcode.component.html
Normal file
1
frontend/src/app/components/qrcode/qrcode.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<canvas #canvas></canvas>
|
||||
25
frontend/src/app/components/qrcode/qrcode.component.spec.ts
Normal file
25
frontend/src/app/components/qrcode/qrcode.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
43
frontend/src/app/components/qrcode/qrcode.component.ts
Normal file
43
frontend/src/app/components/qrcode/qrcode.component.ts
Normal 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() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
20
frontend/src/app/components/start/start.component.html
Normal file
20
frontend/src/app/components/start/start.component.html
Normal 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>
|
||||
3
frontend/src/app/components/start/start.component.scss
Normal file
3
frontend/src/app/components/start/start.component.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.search-container {
|
||||
padding-top: 50px;
|
||||
}
|
||||
25
frontend/src/app/components/start/start.component.spec.ts
Normal file
25
frontend/src/app/components/start/start.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
10
frontend/src/app/components/start/start.component.ts
Normal file
10
frontend/src/app/components/start/start.component.ts
Normal 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';
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
@import "../../../styles.scss";
|
||||
|
||||
.ct-bar-label {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.ct-target-line {
|
||||
stroke: #f5f5f5;
|
||||
stroke-width: 3px;
|
||||
stroke-dasharray: 7px;
|
||||
}
|
||||
|
||||
.ct-area {
|
||||
stroke: none;
|
||||
fill-opacity: 0.9;
|
||||
}
|
||||
|
||||
.ct-label {
|
||||
fill: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.ct-grid {
|
||||
stroke: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* LEGEND */
|
||||
|
||||
.ct-legend {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
left: 0px;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
padding: 0px 0px 0px 30px;
|
||||
top: 90px;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 23px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
li:before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
content: '';
|
||||
border: 3px solid transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
li.inactive:before {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&.ct-legend-inside {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@for $i from 0 to length($ct-series-colors) {
|
||||
.ct-series-#{$i}:before {
|
||||
background-color: nth($ct-series-colors, $i + 1);
|
||||
border-color: nth($ct-series-colors, $i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
657
frontend/src/app/components/statistics/chartist.component.ts
Normal file
657
frontend/src/app/components/statistics/chartist.component.ts
Normal file
@@ -0,0 +1,657 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
SimpleChanges,
|
||||
ViewEncapsulation
|
||||
} from '@angular/core';
|
||||
|
||||
import * as Chartist from 'chartist';
|
||||
|
||||
/**
|
||||
* Possible chart types
|
||||
* @type {String}
|
||||
*/
|
||||
export type ChartType = 'Pie' | 'Bar' | 'Line';
|
||||
|
||||
export type ChartInterfaces =
|
||||
| Chartist.IChartistPieChart
|
||||
| Chartist.IChartistBarChart
|
||||
| Chartist.IChartistLineChart;
|
||||
export type ChartOptions =
|
||||
| Chartist.IBarChartOptions
|
||||
| Chartist.ILineChartOptions
|
||||
| Chartist.IPieChartOptions;
|
||||
export type ResponsiveOptionTuple = Chartist.IResponsiveOptionTuple<
|
||||
ChartOptions
|
||||
>;
|
||||
export type ResponsiveOptions = ResponsiveOptionTuple[];
|
||||
|
||||
/**
|
||||
* Represent a chart event.
|
||||
* For possible values, check the Chartist docs.
|
||||
*/
|
||||
export interface ChartEvent {
|
||||
[eventName: string]: (data: any) => void;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chartist',
|
||||
template: '<ng-content></ng-content>',
|
||||
styleUrls: ['./chartist.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class ChartistComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input()
|
||||
// @ts-ignore
|
||||
public data: Promise<Chartist.IChartistData> | Chartist.IChartistData;
|
||||
|
||||
// @ts-ignore
|
||||
@Input() public type: Promise<ChartType> | ChartType;
|
||||
|
||||
@Input()
|
||||
// @ts-ignore
|
||||
public options: Promise<Chartist.IChartOptions> | Chartist.IChartOptions;
|
||||
|
||||
@Input()
|
||||
// @ts-ignore
|
||||
public responsiveOptions: Promise<ResponsiveOptions> | ResponsiveOptions;
|
||||
|
||||
// @ts-ignore
|
||||
@Input() public events: ChartEvent;
|
||||
|
||||
// @ts-ignore
|
||||
public chart: ChartInterfaces;
|
||||
|
||||
private element: HTMLElement;
|
||||
|
||||
constructor(element: ElementRef) {
|
||||
this.element = element.nativeElement;
|
||||
}
|
||||
|
||||
public ngOnInit(): Promise<ChartInterfaces> {
|
||||
if (!this.type || !this.data) {
|
||||
Promise.reject('Expected at least type and data.');
|
||||
}
|
||||
|
||||
return this.renderChart().then((chart) => {
|
||||
if (this.events !== undefined) {
|
||||
this.bindEvents(chart);
|
||||
}
|
||||
|
||||
return chart;
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
this.update(changes);
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
if (this.chart) {
|
||||
this.chart.detach();
|
||||
}
|
||||
}
|
||||
|
||||
public renderChart(): Promise<ChartInterfaces> {
|
||||
const promises: any[] = [
|
||||
this.type,
|
||||
this.element,
|
||||
this.data,
|
||||
this.options,
|
||||
this.responsiveOptions
|
||||
];
|
||||
|
||||
return Promise.all(promises).then((values) => {
|
||||
const [type, ...args]: any = values;
|
||||
|
||||
if (!(type in Chartist)) {
|
||||
throw new Error(`${type} is not a valid chart type`);
|
||||
}
|
||||
|
||||
this.chart = (Chartist as any)[type](...args);
|
||||
|
||||
return this.chart;
|
||||
});
|
||||
}
|
||||
|
||||
public update(changes: SimpleChanges): void {
|
||||
if (!this.chart || 'type' in changes) {
|
||||
this.renderChart();
|
||||
} else {
|
||||
if (changes.data) {
|
||||
this.data = changes.data.currentValue;
|
||||
}
|
||||
|
||||
if (changes.options) {
|
||||
this.options = changes.options.currentValue;
|
||||
}
|
||||
|
||||
(this.chart as any).update(this.data, this.options);
|
||||
}
|
||||
}
|
||||
|
||||
public bindEvents(chart: any): void {
|
||||
for (const event of Object.keys(this.events)) {
|
||||
chart.on(event, this.events[event]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chartist.js plugin to display a "target" or "goal" line across the chart.
|
||||
* Only tested with bar charts. Works for horizontal and vertical bars.
|
||||
*/
|
||||
(function(window, document, Chartist) {
|
||||
'use strict';
|
||||
|
||||
const defaultOptions = {
|
||||
// The class name so you can style the text
|
||||
className: 'ct-target-line',
|
||||
// The axis to draw the line. y == vertical bars, x == horizontal
|
||||
axis: 'y',
|
||||
// What value the target line should be drawn at
|
||||
value: null
|
||||
};
|
||||
|
||||
Chartist.plugins = Chartist.plugins || {};
|
||||
|
||||
Chartist.plugins.ctTargetLine = function (options: any) {
|
||||
options = Chartist.extend({}, defaultOptions, options);
|
||||
return function ctTargetLine (chart: any) {
|
||||
|
||||
chart.on('created', function(context: any) {
|
||||
const projectTarget = {
|
||||
y: function (chartRect: any, bounds: any, value: any) {
|
||||
const targetLineY = chartRect.y1 - (chartRect.height() / bounds.max * value);
|
||||
|
||||
return {
|
||||
x1: chartRect.x1,
|
||||
x2: chartRect.x2,
|
||||
y1: targetLineY,
|
||||
y2: targetLineY
|
||||
};
|
||||
},
|
||||
x: function (chartRect: any, bounds: any, value: any) {
|
||||
const targetLineX = chartRect.x1 + (chartRect.width() / bounds.max * value);
|
||||
|
||||
return {
|
||||
x1: targetLineX,
|
||||
x2: targetLineX,
|
||||
y1: chartRect.y1,
|
||||
y2: chartRect.y2
|
||||
};
|
||||
}
|
||||
};
|
||||
// @ts-ignore
|
||||
const targetLine = projectTarget[options.axis](context.chartRect, context.bounds, options.value);
|
||||
|
||||
context.svg.elem('line', targetLine, options.className);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
}(window, document, Chartist));
|
||||
|
||||
|
||||
/**
|
||||
* Chartist.js plugin to display a data label on top of the points in a line chart.
|
||||
*
|
||||
*/
|
||||
/* global Chartist */
|
||||
(function(window, document, Chartist) {
|
||||
'use strict';
|
||||
|
||||
const defaultOptions = {
|
||||
labelClass: 'ct-label',
|
||||
labelOffset: {
|
||||
x: 0,
|
||||
y: -10
|
||||
},
|
||||
textAnchor: 'middle',
|
||||
align: 'center',
|
||||
labelInterpolationFnc: Chartist.noop
|
||||
};
|
||||
|
||||
const labelPositionCalculation = {
|
||||
point: function(data: any) {
|
||||
return {
|
||||
x: data.x,
|
||||
y: data.y
|
||||
};
|
||||
},
|
||||
bar: {
|
||||
left: function(data: any) {
|
||||
return {
|
||||
x: data.x1,
|
||||
y: data.y1
|
||||
};
|
||||
},
|
||||
center: function(data: any) {
|
||||
return {
|
||||
x: data.x1 + (data.x2 - data.x1) / 2,
|
||||
y: data.y1
|
||||
};
|
||||
},
|
||||
right: function(data: any) {
|
||||
return {
|
||||
x: data.x2,
|
||||
y: data.y1
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Chartist.plugins = Chartist.plugins || {};
|
||||
Chartist.plugins.ctPointLabels = function(options: any) {
|
||||
|
||||
options = Chartist.extend({}, defaultOptions, options);
|
||||
|
||||
function addLabel(position: any, data: any) {
|
||||
// if x and y exist concat them otherwise output only the existing value
|
||||
const value = data.value.x !== undefined && data.value.y ?
|
||||
(data.value.x + ', ' + data.value.y) :
|
||||
data.value.y || data.value.x;
|
||||
|
||||
data.group.elem('text', {
|
||||
x: position.x + options.labelOffset.x,
|
||||
y: position.y + options.labelOffset.y,
|
||||
style: 'text-anchor: ' + options.textAnchor
|
||||
}, options.labelClass).text(options.labelInterpolationFnc(value));
|
||||
}
|
||||
|
||||
return function ctPointLabels(chart: any) {
|
||||
if (chart instanceof Chartist.Line || chart instanceof Chartist.Bar) {
|
||||
chart.on('draw', function(data: any) {
|
||||
// @ts-ignore
|
||||
const positonCalculator = labelPositionCalculation[data.type]
|
||||
// @ts-ignore
|
||||
&& labelPositionCalculation[data.type][options.align] || labelPositionCalculation[data.type];
|
||||
if (positonCalculator) {
|
||||
addLabel(positonCalculator(data), data);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
}(window, document, Chartist));
|
||||
|
||||
const defaultOptions = {
|
||||
className: '',
|
||||
classNames: false,
|
||||
removeAll: false,
|
||||
legendNames: false,
|
||||
clickable: true,
|
||||
onClick: null,
|
||||
position: 'top'
|
||||
};
|
||||
|
||||
Chartist.plugins.legend = function (options: any) {
|
||||
let cachedDOMPosition;
|
||||
// Catch invalid options
|
||||
if (options && options.position) {
|
||||
if (!(options.position === 'top' || options.position === 'bottom' || options.position instanceof HTMLElement)) {
|
||||
throw Error('The position you entered is not a valid position');
|
||||
}
|
||||
if (options.position instanceof HTMLElement) {
|
||||
// Detatch DOM element from options object, because Chartist.extend
|
||||
// currently chokes on circular references present in HTMLElements
|
||||
cachedDOMPosition = options.position;
|
||||
delete options.position;
|
||||
}
|
||||
}
|
||||
|
||||
options = Chartist.extend({}, defaultOptions, options);
|
||||
|
||||
if (cachedDOMPosition) {
|
||||
// Reattatch the DOM Element position if it was removed before
|
||||
options.position = cachedDOMPosition;
|
||||
}
|
||||
|
||||
return function legend(chart: any) {
|
||||
|
||||
function removeLegendElement() {
|
||||
const legendElement = chart.container.querySelector('.ct-legend');
|
||||
if (legendElement) {
|
||||
legendElement.parentNode.removeChild(legendElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Set a unique className for each series so that when a series is removed,
|
||||
// the other series still have the same color.
|
||||
function setSeriesClassNames() {
|
||||
chart.data.series = chart.data.series.map(function (series: any, seriesIndex: any) {
|
||||
if (typeof series !== 'object') {
|
||||
series = {
|
||||
value: series
|
||||
};
|
||||
}
|
||||
series.className = series.className || chart.options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex);
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
function createLegendElement() {
|
||||
const legendElement = document.createElement('ul');
|
||||
legendElement.className = 'ct-legend';
|
||||
if (chart instanceof Chartist.Pie) {
|
||||
legendElement.classList.add('ct-legend-inside');
|
||||
}
|
||||
if (typeof options.className === 'string' && options.className.length > 0) {
|
||||
legendElement.classList.add(options.className);
|
||||
}
|
||||
if (chart.options.width) {
|
||||
legendElement.style.cssText = 'width: ' + chart.options.width + 'px;margin: 0 auto;';
|
||||
}
|
||||
return legendElement;
|
||||
}
|
||||
|
||||
// Get the right array to use for generating the legend.
|
||||
function getLegendNames(useLabels: any) {
|
||||
return options.legendNames || (useLabels ? chart.data.labels : chart.data.series);
|
||||
}
|
||||
|
||||
// Initialize the array that associates series with legends.
|
||||
// -1 indicates that there is no legend associated with it.
|
||||
function initSeriesMetadata(useLabels: any) {
|
||||
const seriesMetadata = new Array(chart.data.series.length);
|
||||
for (let i = 0; i < chart.data.series.length; i++) {
|
||||
seriesMetadata[i] = {
|
||||
data: chart.data.series[i],
|
||||
label: useLabels ? chart.data.labels[i] : null,
|
||||
legend: -1
|
||||
};
|
||||
}
|
||||
return seriesMetadata;
|
||||
}
|
||||
|
||||
function createNameElement(i: any, legendText: any, classNamesViable: any) {
|
||||
const li = document.createElement('li');
|
||||
li.classList.add('ct-series-' + i);
|
||||
// Append specific class to a legend element, if viable classes are given
|
||||
if (classNamesViable) {
|
||||
li.classList.add(options.classNames[i]);
|
||||
}
|
||||
li.setAttribute('data-legend', i);
|
||||
li.textContent = legendText;
|
||||
return li;
|
||||
}
|
||||
|
||||
// Append the legend element to the DOM
|
||||
function appendLegendToDOM(legendElement: any) {
|
||||
if (!(options.position instanceof HTMLElement)) {
|
||||
switch (options.position) {
|
||||
case 'top':
|
||||
chart.container.insertBefore(legendElement, chart.container.childNodes[0]);
|
||||
break;
|
||||
|
||||
case 'bottom':
|
||||
chart.container.insertBefore(legendElement, null);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Appends the legend element as the last child of a given HTMLElement
|
||||
options.position.insertBefore(legendElement, null);
|
||||
}
|
||||
}
|
||||
|
||||
function addClickHandler(legendElement: any, legends: any, seriesMetadata: any, useLabels: any) {
|
||||
legendElement.addEventListener('click', function(e: any) {
|
||||
const li = e.target;
|
||||
if (li.parentNode !== legendElement || !li.hasAttribute('data-legend'))
|
||||
return;
|
||||
e.preventDefault();
|
||||
|
||||
const legendIndex = parseInt(li.getAttribute('data-legend'));
|
||||
const legend = legends[legendIndex];
|
||||
|
||||
if (!legend.active) {
|
||||
legend.active = true;
|
||||
li.classList.remove('inactive');
|
||||
} else {
|
||||
legend.active = false;
|
||||
li.classList.add('inactive');
|
||||
|
||||
const activeCount = legends.filter(function(legend: any) { return legend.active; }).length;
|
||||
if (!options.removeAll && activeCount == 0) {
|
||||
// If we can't disable all series at the same time, let's
|
||||
// reenable all of them:
|
||||
for (let i = 0; i < legends.length; i++) {
|
||||
legends[i].active = true;
|
||||
legendElement.childNodes[i].classList.remove('inactive');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newSeries = [];
|
||||
const newLabels = [];
|
||||
|
||||
for (let i = 0; i < seriesMetadata.length; i++) {
|
||||
if (seriesMetadata[i].legend !== -1 && legends[seriesMetadata[i].legend].active) {
|
||||
newSeries.push(seriesMetadata[i].data);
|
||||
newLabels.push(seriesMetadata[i].label);
|
||||
}
|
||||
}
|
||||
|
||||
chart.data.series = newSeries;
|
||||
if (useLabels) {
|
||||
chart.data.labels = newLabels;
|
||||
}
|
||||
|
||||
chart.update();
|
||||
|
||||
if (options.onClick) {
|
||||
options.onClick(chart, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeLegendElement();
|
||||
|
||||
const legendElement = createLegendElement();
|
||||
const useLabels = chart instanceof Chartist.Pie && chart.data.labels && chart.data.labels.length;
|
||||
const legendNames = getLegendNames(useLabels);
|
||||
const seriesMetadata = initSeriesMetadata(useLabels);
|
||||
const legends: any = [];
|
||||
|
||||
// Check if given class names are viable to append to legends
|
||||
const classNamesViable = Array.isArray(options.classNames) && options.classNames.length === legendNames.length;
|
||||
|
||||
// Loop through all legends to set each name in a list item.
|
||||
legendNames.forEach(function (legend: any, i: any) {
|
||||
const legendText = legend.name || legend;
|
||||
const legendSeries = legend.series || [i];
|
||||
|
||||
const li = createNameElement(i, legendText, classNamesViable);
|
||||
legendElement.appendChild(li);
|
||||
|
||||
legendSeries.forEach(function(seriesIndex: any) {
|
||||
seriesMetadata[seriesIndex].legend = i;
|
||||
});
|
||||
|
||||
legends.push({
|
||||
text: legendText,
|
||||
series: legendSeries,
|
||||
active: true
|
||||
});
|
||||
});
|
||||
|
||||
chart.on('created', function (data: any) {
|
||||
appendLegendToDOM(legendElement);
|
||||
});
|
||||
|
||||
if (options.clickable) {
|
||||
setSeriesClassNames();
|
||||
addClickHandler(legendElement, legends, seriesMetadata, useLabels);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Chartist.plugins.tooltip = function (options: any) {
|
||||
options = Chartist.extend({}, defaultOptions, options);
|
||||
|
||||
return function tooltip(chart: any) {
|
||||
let tooltipSelector = options.pointClass;
|
||||
if (chart 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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
227
frontend/src/app/components/statistics/statistics.component.ts
Normal file
227
frontend/src/app/components/statistics/statistics.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
135
frontend/src/app/components/television/television.component.ts
Normal file
135
frontend/src/app/components/television/television.component.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
.adjust-btn-padding {
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
#arrow {
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
width: 40px;
|
||||
right: -1px;
|
||||
|
||||
width: 40px;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
</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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user