Initial code commit.
13
frontend/.editorconfig
Normal file
@@ -0,0 +1,13 @@
|
||||
# Editor configuration, see http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
39
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
127
frontend/angular.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"mempool": {
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"projectType": "application",
|
||||
"prefix": "app",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"styleext": "scss"
|
||||
}
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/mempool",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "src/tsconfig.app.json",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/.htaccess"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractCss": true,
|
||||
"namedChunks": false,
|
||||
"aot": true,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "mempool:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "mempool:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "mempool:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "src/tsconfig.spec.json",
|
||||
"karmaConfig": "src/karma.conf.js",
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": [],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": [
|
||||
"src/tsconfig.app.json",
|
||||
"src/tsconfig.spec.json"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mempool-e2e": {
|
||||
"root": "e2e/",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "mempool:serve"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": "e2e/tsconfig.e2e.json",
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultProject": "mempool"
|
||||
}
|
||||
28
frontend/e2e/protractor.conf.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Protractor configuration file, see link for more information
|
||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
||||
|
||||
const { SpecReporter } = require('jasmine-spec-reporter');
|
||||
|
||||
exports.config = {
|
||||
allScriptsTimeout: 11000,
|
||||
specs: [
|
||||
'./src/**/*.e2e-spec.ts'
|
||||
],
|
||||
capabilities: {
|
||||
'browserName': 'chrome'
|
||||
},
|
||||
directConnect: true,
|
||||
baseUrl: 'http://localhost:4200/',
|
||||
framework: 'jasmine',
|
||||
jasmineNodeOpts: {
|
||||
showColors: true,
|
||||
defaultTimeoutInterval: 30000,
|
||||
print: function() {}
|
||||
},
|
||||
onPrepare() {
|
||||
require('ts-node').register({
|
||||
project: require('path').join(__dirname, './tsconfig.e2e.json')
|
||||
});
|
||||
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
|
||||
}
|
||||
};
|
||||
14
frontend/e2e/src/app.e2e-spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { AppPage } from './app.po';
|
||||
|
||||
describe('workspace-project App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getParagraphText()).toEqual('Welcome to app!');
|
||||
});
|
||||
});
|
||||
11
frontend/e2e/src/app.po.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { browser, by, element } from 'protractor';
|
||||
|
||||
export class AppPage {
|
||||
navigateTo() {
|
||||
return browser.get('/');
|
||||
}
|
||||
|
||||
getParagraphText() {
|
||||
return element(by.css('app-root h1')).getText();
|
||||
}
|
||||
}
|
||||
13
frontend/e2e/tsconfig.e2e.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/app",
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
53
frontend/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "mempool",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --aot --proxy-config proxy.conf.json",
|
||||
"build": "ng build --prod --vendorChunk=false --build-optimizer=true",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^8.0.0",
|
||||
"@angular/common": "^8.0.0",
|
||||
"@angular/compiler": "^8.0.0",
|
||||
"@angular/core": "^8.0.0",
|
||||
"@angular/forms": "^8.0.0",
|
||||
"@angular/platform-browser": "^8.0.0",
|
||||
"@angular/platform-browser-dynamic": "^8.0.0",
|
||||
"@angular/router": "^8.0.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^3.3.1",
|
||||
"bootstrap": "^4.3.1",
|
||||
"chartist": "^0.11.2",
|
||||
"core-js": "^2.6.9",
|
||||
"ng-chartist": "^2.0.0-beta.1",
|
||||
"rxjs": "^6.5.2",
|
||||
"tslib": "^1.9.0",
|
||||
"zone.js": "~0.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "~0.800.0",
|
||||
"@angular/cli": "~8.0.2",
|
||||
"@angular/compiler-cli": "^8.0.0",
|
||||
"@angular/language-service": "^8.0.0",
|
||||
"@types/chartist": "^0.9.46",
|
||||
"@types/jasmine": "^2.8.16",
|
||||
"@types/jasminewd2": "^2.0.6",
|
||||
"@types/node": "~8.9.4",
|
||||
"codelyzer": "~5.1.0",
|
||||
"jasmine-core": "~2.99.1",
|
||||
"jasmine-spec-reporter": "~4.2.1",
|
||||
"karma": "~1.7.1",
|
||||
"karma-chrome-launcher": "~2.2.0",
|
||||
"karma-coverage-istanbul-reporter": "~1.4.2",
|
||||
"karma-jasmine": "~1.1.1",
|
||||
"karma-jasmine-html-reporter": "^0.2.2",
|
||||
"protractor": "~5.3.0",
|
||||
"ts-node": "~7.0.0",
|
||||
"tslint": "~5.15.0",
|
||||
"typescript": "~3.4.3"
|
||||
}
|
||||
}
|
||||
6
frontend/proxy.conf.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8999/",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
7
frontend/src/.htaccess
Normal file
@@ -0,0 +1,7 @@
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_FILENAME} -s [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -l [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^.*$ - [NC,L]
|
||||
|
||||
RewriteRule ^(.*) /index.html [NC,L]
|
||||
41
frontend/src/app/about/about.component.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<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 visualizer and statistics website focused on SegWit.</p>
|
||||
<p>Created by <a href="http://t.me/softcrypto">@softcrypto</a> (Telegram). <a href="https://twitter.com/softcrypt0">@softcrypt0</a> (Twitter).
|
||||
<br />Designed by <a href="https://emeraldo.io">emeraldo.io</a>.</p>
|
||||
|
||||
|
||||
<h2>Fee API</h2>
|
||||
|
||||
<div class="col-4 mx-auto">
|
||||
<input class="form-control" type="text" value="https://mempool.space:8999/api/v1/fees/recommended" readonly>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<h1>Donate</h1>
|
||||
<h3>Segwit native</h3>
|
||||
<img src="./assets/btc-qr-code-segwit.png" width="200" height="200" />
|
||||
<br />
|
||||
bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t
|
||||
|
||||
<br /><br />
|
||||
<h3>Segwit compatibility</h3>
|
||||
<img src="./assets/btc-qr-code.png" width="200" height="200" />
|
||||
<br />
|
||||
3Ccig4G4u8hbExnxBJHeE5ZmxxWxvEQ65f
|
||||
|
||||
|
||||
<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>
|
||||
0
frontend/src/app/about/about.component.scss
Normal file
15
frontend/src/app/about/about.component.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about',
|
||||
templateUrl: './about.component.html',
|
||||
styleUrls: ['./about.component.scss']
|
||||
})
|
||||
export class AboutComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
}
|
||||
40
frontend/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { BlockchainComponent } from './blockchain/blockchain.component';
|
||||
import { AboutComponent } from './about/about.component';
|
||||
import { StatisticsComponent } from './statistics/statistics.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
children: [],
|
||||
component: BlockchainComponent
|
||||
},
|
||||
{
|
||||
path: 'tx/:id',
|
||||
children: [],
|
||||
component: BlockchainComponent
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
children: [],
|
||||
component: AboutComponent
|
||||
},
|
||||
{
|
||||
path: 'statistics',
|
||||
component: StatisticsComponent,
|
||||
},
|
||||
{
|
||||
path: 'graphs',
|
||||
component: StatisticsComponent,
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
}
|
||||
];
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
32
frontend/src/app/app.component.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<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" [ngClass]="{'active': txActive.isActive}" [routerLinkActiveOptions]="{exact: true}">
|
||||
<a class="nav-link" routerLink="/" (click)="collapse()">Blocks</a>
|
||||
<a class="nav-link" routerLink="/tx" routerLinkActive #txActive="routerLinkActive" style="display: none;"></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="/about" (click)="collapse()">About</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form [formGroup]="searchForm" class="form-inline mt-2 mt-md-0 mr-4" (submit)="searchForm.valid && search()" novalidate>
|
||||
<input formControlName="txId" required style="width: 300px;" class="form-control mr-sm-2" type="text" placeholder="Track transaction (TXID)" aria-label="Search">
|
||||
<button class="btn btn-primary my-2 my-sm-0" type="submit">Track</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<br />
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
28
frontend/src/app/app.component.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
li.nav-item.active {
|
||||
background-color: #653b9c;
|
||||
}
|
||||
|
||||
li.nav-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0rem 1rem;
|
||||
}
|
||||
li.nav-item {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
li.nav-item a {
|
||||
color: #ffffff;
|
||||
}
|
||||
52
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { MemPoolService } from './services/mem-pool.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
navCollapsed = false;
|
||||
isOffline = false;
|
||||
searchForm: FormGroup;
|
||||
|
||||
constructor(
|
||||
private memPoolService: MemPoolService,
|
||||
private router: Router,
|
||||
private formBuilder: FormBuilder,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.searchForm = this.formBuilder.group({
|
||||
txId: ['', Validators.pattern('^[a-fA-F0-9]{64}$')],
|
||||
});
|
||||
|
||||
this.memPoolService.isOffline
|
||||
.subscribe((state) => {
|
||||
this.isOffline = state;
|
||||
});
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
this.navCollapsed = !this.navCollapsed;
|
||||
}
|
||||
|
||||
search() {
|
||||
const txId = this.searchForm.value.txId;
|
||||
if (txId) {
|
||||
if (window.location.pathname === '/' || window.location.pathname.substr(0, 4) === '/tx/') {
|
||||
window.history.pushState({}, '', `/tx/${txId}`);
|
||||
} else {
|
||||
this.router.navigate(['/tx/', txId]);
|
||||
}
|
||||
this.memPoolService.txIdSearch.next(txId);
|
||||
this.searchForm.setValue({
|
||||
txId: '',
|
||||
});
|
||||
this.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
45
frontend/src/app/app.module.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { BlockchainComponent } from './blockchain/blockchain.component';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { SharedModule } from './shared/shared.module';
|
||||
import { MemPoolService } from './services/mem-pool.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { FooterComponent } from './footer/footer.component';
|
||||
import { AboutComponent } from './about/about.component';
|
||||
import { TxBubbleComponent } from './tx-bubble/tx-bubble.component';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { BlockModalComponent } from './block-modal/block-modal.component';
|
||||
import { StatisticsComponent } from './statistics/statistics.component';
|
||||
import { ProjectedBlockModalComponent } from './projected-block-modal/projected-block-modal.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
BlockchainComponent,
|
||||
FooterComponent,
|
||||
StatisticsComponent,
|
||||
AboutComponent,
|
||||
TxBubbleComponent,
|
||||
BlockModalComponent,
|
||||
ProjectedBlockModalComponent,
|
||||
],
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
BrowserModule,
|
||||
HttpClientModule,
|
||||
AppRoutingModule,
|
||||
SharedModule,
|
||||
],
|
||||
providers: [
|
||||
MemPoolService,
|
||||
],
|
||||
entryComponents: [
|
||||
BlockModalComponent,
|
||||
ProjectedBlockModalComponent,
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
||||
45
frontend/src/app/block-modal/block-modal.component.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Fee distribution for block <a href="https://www.blockstream.info/block-height/{{ block.height }}" target="_blank">#{{ block.height }}</a></h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div>
|
||||
<table class="table table-borderless table-sm">
|
||||
<tr>
|
||||
<th>Median fee:</th>
|
||||
<td>~{{ block.medianFee | ceil }} sat/vB <span *ngIf="conversions">(~<span class="green-color">{{ conversions.USD * (block.medianFee/100000000)*250 | currency:'USD':'symbol':'1.2-2' }}</span>/tx)</span></td>
|
||||
<th>Block size:</th>
|
||||
<td>{{ block.size | bytes: 2 }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Fee span:</th>
|
||||
<td class="yellow-color">{{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB</td>
|
||||
<th>Tx count:</th>
|
||||
<td>{{ block.nTx }} transactions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total fees:</th>
|
||||
<td>{{ (block.fees - 12.5) | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * (block.fees - 12.5) | currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
|
||||
<th>Block reward + fees:</th>
|
||||
<td>{{ block.fees | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * block.fees | currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div style="height: 400px;" *ngIf="mempoolVsizeFeesData; else loadingFees">
|
||||
<app-chartist
|
||||
[data]="mempoolVsizeFeesData"
|
||||
[type]="'Bar'"
|
||||
[options]="mempoolVsizeFeesOptions">
|
||||
</app-chartist>
|
||||
</div>
|
||||
<ng-template #loadingFees>
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
7
frontend/src/app/block-modal/block-modal.component.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.yellow-color {
|
||||
color: #ffd800;
|
||||
}
|
||||
|
||||
.green-color {
|
||||
color: #3bcc49;
|
||||
}
|
||||
73
frontend/src/app/block-modal/block-modal.component.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Component, OnInit, Input } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { IBlock } from '../blockchain/interfaces';
|
||||
import { MemPoolService } from '../services/mem-pool.service';
|
||||
import * as Chartist from 'chartist';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block-modal',
|
||||
templateUrl: './block-modal.component.html',
|
||||
styleUrls: ['./block-modal.component.scss']
|
||||
})
|
||||
export class BlockModalComponent implements OnInit {
|
||||
@Input() block: IBlock;
|
||||
|
||||
mempoolVsizeFeesData: any;
|
||||
mempoolVsizeFeesOptions: any;
|
||||
conversions: any;
|
||||
|
||||
constructor(
|
||||
public activeModal: NgbActiveModal,
|
||||
private apiService: ApiService,
|
||||
private memPoolService: MemPoolService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
this.mempoolVsizeFeesOptions = {
|
||||
showArea: false,
|
||||
showLine: false,
|
||||
fullWidth: false,
|
||||
showPoint: false,
|
||||
low: 0,
|
||||
axisX: {
|
||||
position: 'start',
|
||||
showLabel: false,
|
||||
offset: 0,
|
||||
showGrid: false,
|
||||
},
|
||||
axisY: {
|
||||
position: 'end',
|
||||
scaleMinSpace: 40,
|
||||
showGrid: false,
|
||||
},
|
||||
plugins: [
|
||||
Chartist.plugins.tooltip({
|
||||
tooltipOffset: {
|
||||
x: 15,
|
||||
y: 250
|
||||
},
|
||||
transformTooltipTextFnc: (value: number): any => {
|
||||
return Math.ceil(value) + ' sat/vB';
|
||||
},
|
||||
anchorToPoint: false,
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
this.memPoolService.conversions
|
||||
.subscribe((conversions) => {
|
||||
this.conversions = conversions;
|
||||
});
|
||||
|
||||
this.apiService.listTransactionsForBlock$(this.block.height)
|
||||
.subscribe((data) => {
|
||||
this.mempoolVsizeFeesData = {
|
||||
labels: data.map((x, i) => i),
|
||||
series: [data.map((tx) => tx.fpv)]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
69
frontend/src/app/blockchain/blockchain.component.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<div *ngIf="blocks.length === 0" class="text-center">
|
||||
<h3>Loading blocks...</h3>
|
||||
<br>
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
<div *ngIf="blocks.length !== 0 && 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">
|
||||
|
||||
<div class="projected-blocks-container">
|
||||
<div *ngFor="let projectedBlock of projectedBlocks; let i = index; trackBy: trackByProjectedFn">
|
||||
<div (click)="openProjectedBlockModal(projectedBlock, i);" class="bitcoin-block text-center projected-block" id="projected-block-{{ i }}" [ngStyle]="getStyleForProjectedBlockAtIndex(i)">
|
||||
<div class="block-body" *ngIf="projectedBlocks?.length">
|
||||
<div class="fees">
|
||||
~{{ projectedBlock.medianFee | ceil }} sat/vB
|
||||
<br/>
|
||||
<span class="yellow-color">{{ projectedBlock.minFee | ceil }} - {{ projectedBlock.maxFee | 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 && projectedBlocks?.length >= 4 && (projectedBlock.blockWeight / 4000000 | ceil) > 1">
|
||||
<div class="time-difference">+{{ projectedBlock.blockWeight / 4000000 | ceil }} blocks</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<span class="animated-border"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="blocks-container" *ngIf="blocks.length">
|
||||
|
||||
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn" >
|
||||
<div (click)="openBlockModal(block);" class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" [ngStyle]="getStyleForBlock(block)">
|
||||
|
||||
<div class="block-height">
|
||||
<a href="https://www.blockstream.info/block-height/{{ block.height }}" target="_blank">#{{ block.height }}</a>
|
||||
</div>
|
||||
|
||||
<div class="block-body">
|
||||
<div class="fees">
|
||||
~{{ block.medianFee | ceil }} sat/vB
|
||||
<br/>
|
||||
<span class="yellow-color">{{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB</span>
|
||||
</div>
|
||||
|
||||
<div class="block-size">{{ block.size | bytes: 2 }}</div>
|
||||
<div class="transaction-count">{{ block.nTx }} transactions</div>
|
||||
<br /><br />
|
||||
<div class="time-difference">{{ getTimeSinceMined(block) }} ago</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="divider" *ngIf="blocks.length"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<app-tx-bubble *ngIf="blocks?.length && txTrackingTx" [tx]="txTrackingTx" [arrowPosition]="txBubbleArrowPosition" [ngStyle]="txBubbleStyle" [latestBlockHeight]="blocks[0].height" [txTrackingBlockHeight]="txTrackingBlockHeight"></app-tx-bubble>
|
||||
|
||||
<app-footer></app-footer>
|
||||
195
frontend/src/app/blockchain/blockchain.component.scss
Normal file
@@ -0,0 +1,195 @@
|
||||
.block-filled {
|
||||
width: 100%;
|
||||
background-color: #aeffb0;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.block-filled .segwit {
|
||||
background-color: #16ca1a;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.projected-blocks-container {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
left: 0px;
|
||||
|
||||
animation: opacityPulse 2s ease-out;
|
||||
animation-iteration-count: infinite;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.projected-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;
|
||||
}
|
||||
|
||||
#divider {
|
||||
width: 3px;
|
||||
height: 3000px;
|
||||
left: 0;
|
||||
top: -1000px;
|
||||
background-image: url('/assets/divider-new.png');
|
||||
background-repeat: repeat-y;
|
||||
position: absolute;
|
||||
margin-bottom: 120px;
|
||||
}
|
||||
|
||||
#divider > img {
|
||||
position: absolute;
|
||||
left: -100px;
|
||||
top: -28px;
|
||||
}
|
||||
|
||||
.fees {
|
||||
font-size: 10px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.btcblockmiddle {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.breakRow {
|
||||
height: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.yellow-color {
|
||||
color: #ffd800;
|
||||
}
|
||||
|
||||
.transaction-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.blockchain-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.position-container {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(50% - 60px);
|
||||
}
|
||||
|
||||
.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) {
|
||||
#divider {
|
||||
top: -50px;
|
||||
}
|
||||
.position-container {
|
||||
top: 100px;
|
||||
}
|
||||
.projected-blocks-container {
|
||||
position: absolute;
|
||||
left: -165px;
|
||||
top: -40px;
|
||||
}
|
||||
.block-height {
|
||||
bottom: 125px;
|
||||
left: inherit;
|
||||
text-shadow: inherit;
|
||||
z-index: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.position-container {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.projected-block.bitcoin-block::after {
|
||||
background-color: #403834;
|
||||
}
|
||||
|
||||
.projected-block.bitcoin-block::before {
|
||||
background-color: #2d2825;
|
||||
}
|
||||
}
|
||||
|
||||
.black-background {
|
||||
background-color: #11131f;
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
}
|
||||
272
frontend/src/app/blockchain/blockchain.component.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Component, OnInit, OnDestroy, Renderer2, HostListener } from '@angular/core';
|
||||
import { IMempoolDefaultResponse, IBlock, IProjectedBlock, ITransaction } from './interfaces';
|
||||
import { retryWhen, tap } from 'rxjs/operators';
|
||||
import { MemPoolService } from '../services/mem-pool.service';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { BlockModalComponent } from '../block-modal/block-modal.component';
|
||||
import { ProjectedBlockModalComponent } from '../projected-block-modal/projected-block-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blockchain',
|
||||
templateUrl: './blockchain.component.html',
|
||||
styleUrls: ['./blockchain.component.scss']
|
||||
})
|
||||
export class BlockchainComponent implements OnInit, OnDestroy {
|
||||
blocks: IBlock[] = [];
|
||||
projectedBlocks: IProjectedBlock[] = [];
|
||||
subscription: any;
|
||||
socket: any;
|
||||
innerWidth: any;
|
||||
txBubbleStyle: any = {};
|
||||
|
||||
txTrackingLoading = false;
|
||||
txTrackingEnabled = false;
|
||||
txTrackingTx: ITransaction | null = null;
|
||||
txTrackingBlockHeight = 0;
|
||||
txShowTxNotFound = false;
|
||||
txBubbleArrowPosition = 'top';
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
onResize(event: Event) {
|
||||
this.innerWidth = window.innerWidth;
|
||||
this.moveTxBubbleToPosition();
|
||||
}
|
||||
|
||||
constructor(
|
||||
private memPoolService: MemPoolService,
|
||||
private apiService: ApiService,
|
||||
private renderer: Renderer2,
|
||||
private route: ActivatedRoute,
|
||||
private modalService: NgbModal,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
this.txBubbleStyle = {
|
||||
'position': 'absolute',
|
||||
'top': '425px',
|
||||
'visibility': 'hidden',
|
||||
};
|
||||
|
||||
this.innerWidth = window.innerWidth;
|
||||
this.socket = this.apiService.websocketSubject;
|
||||
this.subscription = this.socket
|
||||
.pipe(
|
||||
retryWhen((errors: any) => errors.pipe(
|
||||
tap(() => this.memPoolService.isOffline.next(true))))
|
||||
)
|
||||
.subscribe((response: IMempoolDefaultResponse) => {
|
||||
this.memPoolService.isOffline.next(false);
|
||||
if (response.mempoolInfo && response.txPerSecond !== undefined) {
|
||||
this.memPoolService.loaderSubject.next({
|
||||
memPoolInfo: response.mempoolInfo,
|
||||
txPerSecond: response.txPerSecond,
|
||||
vBytesPerSecond: response.vBytesPerSecond,
|
||||
});
|
||||
}
|
||||
if (response.blocks && response.blocks.length) {
|
||||
this.blocks = response.blocks;
|
||||
this.blocks.reverse();
|
||||
}
|
||||
if (response.block) {
|
||||
if (!this.blocks.some((block) => response.block !== undefined && response.block.height === block.height )) {
|
||||
this.blocks.unshift(response.block);
|
||||
if (this.blocks.length >= 8) {
|
||||
this.blocks.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.conversions) {
|
||||
this.memPoolService.conversions.next(response.conversions);
|
||||
}
|
||||
if (response.projectedBlocks) {
|
||||
this.projectedBlocks = response.projectedBlocks;
|
||||
const mempoolWeight = this.projectedBlocks.map((block) => block.blockWeight).reduce((a, b) => a + b);
|
||||
this.memPoolService.mempoolWeight.next(mempoolWeight);
|
||||
}
|
||||
if (response['track-tx']) {
|
||||
if (response['track-tx'].tracking) {
|
||||
this.txTrackingEnabled = true;
|
||||
this.txTrackingBlockHeight = response['track-tx'].blockHeight;
|
||||
if (response['track-tx'].tx) {
|
||||
this.txTrackingTx = response['track-tx'].tx;
|
||||
this.txTrackingLoading = false;
|
||||
}
|
||||
} else {
|
||||
this.txTrackingEnabled = false;
|
||||
this.txTrackingTx = null;
|
||||
this.txTrackingBlockHeight = 0;
|
||||
}
|
||||
if (response['track-tx'].message && response['track-tx'].message === 'not-found') {
|
||||
this.txTrackingLoading = false;
|
||||
this.txShowTxNotFound = true;
|
||||
setTimeout(() => { this.txShowTxNotFound = false; }, 2000);
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.moveTxBubbleToPosition();
|
||||
});
|
||||
}
|
||||
},
|
||||
(err: Error) => console.log(err)
|
||||
);
|
||||
this.renderer.addClass(document.body, 'disable-scroll');
|
||||
|
||||
this.route.paramMap
|
||||
.subscribe((params: ParamMap) => {
|
||||
const txId: string | null = params.get('id');
|
||||
if (!txId) {
|
||||
return;
|
||||
}
|
||||
this.txTrackingLoading = true;
|
||||
this.socket.next({'action': 'track-tx', 'txId': txId});
|
||||
});
|
||||
|
||||
this.memPoolService.txIdSearch
|
||||
.subscribe((txId) => {
|
||||
if (txId) {
|
||||
this.txTrackingLoading = true;
|
||||
this.socket.next({'action': 'track-tx', 'txId': txId});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
moveTxBubbleToPosition() {
|
||||
let element: HTMLElement | null = null;
|
||||
if (this.txTrackingBlockHeight === 0) {
|
||||
const index = this.projectedBlocks.findIndex((pB) => pB.hasMytx);
|
||||
if (index > -1) {
|
||||
element = document.getElementById('projected-block-' + index);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
element = document.getElementById('bitcoin-block-' + this.txTrackingBlockHeight);
|
||||
}
|
||||
|
||||
this.txBubbleStyle['visibility'] = 'visible';
|
||||
this.txBubbleStyle['position'] = 'absolute';
|
||||
|
||||
if (!element) {
|
||||
if (this.innerWidth <= 768) {
|
||||
this.txBubbleArrowPosition = 'bottom';
|
||||
this.txBubbleStyle['left'] = window.innerWidth / 2 - 50 + 'px';
|
||||
this.txBubbleStyle['bottom'] = '270px';
|
||||
this.txBubbleStyle['top'] = 'inherit';
|
||||
this.txBubbleStyle['position'] = 'fixed';
|
||||
} else {
|
||||
this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px';
|
||||
this.txBubbleArrowPosition = 'right';
|
||||
this.txBubbleStyle['top'] = '425px';
|
||||
}
|
||||
} else {
|
||||
this.txBubbleArrowPosition = 'top';
|
||||
const domRect: DOMRect | ClientRect = element.getBoundingClientRect();
|
||||
this.txBubbleStyle['left'] = domRect.left - 50 + 'px';
|
||||
this.txBubbleStyle['top'] = domRect.top + 125 + window.scrollY + 'px';
|
||||
|
||||
if (domRect.left + 100 > window.innerWidth) {
|
||||
this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px';
|
||||
this.txBubbleArrowPosition = 'right';
|
||||
} else if (domRect.left + 220 > window.innerWidth) {
|
||||
this.txBubbleStyle['left'] = window.innerWidth - 240 + 'px';
|
||||
this.txBubbleArrowPosition = 'top-right';
|
||||
} else {
|
||||
this.txBubbleStyle['left'] = domRect.left + 15 + 'px';
|
||||
}
|
||||
|
||||
if (domRect.left < 86) {
|
||||
this.txBubbleArrowPosition = 'top-left';
|
||||
this.txBubbleStyle['left'] = 125 + 'px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTimeSinceMined(block: IBlock): string {
|
||||
const minutes = ((new Date().getTime()) - (new Date(block.time * 1000).getTime())) / 1000 / 60;
|
||||
if (minutes >= 120) {
|
||||
return Math.floor(minutes / 60) + ' hours';
|
||||
}
|
||||
if (minutes >= 60) {
|
||||
return Math.floor(minutes / 60) + ' hour';
|
||||
}
|
||||
if (minutes <= 1) {
|
||||
return '< 1 minute';
|
||||
}
|
||||
if (minutes === 1) {
|
||||
return '1 minute';
|
||||
}
|
||||
return Math.round(minutes) + ' minutes';
|
||||
}
|
||||
|
||||
getStyleForBlock(block: IBlock) {
|
||||
const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100;
|
||||
if (this.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%)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getStyleForProjectedBlockAtIndex(index: number) {
|
||||
const greenBackgroundHeight = 100 - (this.projectedBlocks[index].blockWeight / 4000000) * 100;
|
||||
if (this.innerWidth <= 768) {
|
||||
if (index === 3) {
|
||||
return {
|
||||
'top': 40 + index * 155 + 'px'
|
||||
};
|
||||
}
|
||||
return {
|
||||
'top': 40 + index * 155 + 'px',
|
||||
'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%,
|
||||
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
|
||||
};
|
||||
} else {
|
||||
if (index === 3) {
|
||||
return {
|
||||
'right': 40 + index * 155 + 'px'
|
||||
};
|
||||
}
|
||||
return {
|
||||
'right': 40 + index * 155 + 'px',
|
||||
'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%,
|
||||
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
trackByProjectedFn(index: number) {
|
||||
return index;
|
||||
}
|
||||
|
||||
trackByBlocksFn(index: number, item: IBlock) {
|
||||
return item.height;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
this.renderer.removeClass(document.body, 'disable-scroll');
|
||||
}
|
||||
|
||||
openBlockModal(block: IBlock) {
|
||||
const modalRef = this.modalService.open(BlockModalComponent, { size: 'lg' });
|
||||
modalRef.componentInstance.block = block;
|
||||
}
|
||||
|
||||
openProjectedBlockModal(block: IBlock, index: number) {
|
||||
const modalRef = this.modalService.open(ProjectedBlockModalComponent, { size: 'lg' });
|
||||
modalRef.componentInstance.block = block;
|
||||
modalRef.componentInstance.index = index;
|
||||
}
|
||||
}
|
||||
176
frontend/src/app/blockchain/interfaces.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
export interface IMempoolInfo {
|
||||
size: number;
|
||||
bytes: number;
|
||||
usage: number;
|
||||
maxmempool: number;
|
||||
mempoolminfee: number;
|
||||
minrelaytxfee: number;
|
||||
}
|
||||
|
||||
export interface IMempoolDefaultResponse {
|
||||
mempoolInfo?: IMempoolInfo;
|
||||
blocks?: IBlock[];
|
||||
block?: IBlock;
|
||||
projectedBlocks?: IProjectedBlock[];
|
||||
txPerSecond?: number;
|
||||
vBytesPerSecond: number;
|
||||
'track-tx'?: ITrackTx;
|
||||
conversions?: any;
|
||||
}
|
||||
|
||||
export interface ITrackTx {
|
||||
tx?: ITransaction;
|
||||
blockHeight: number;
|
||||
tracking: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface IProjectedBlock {
|
||||
blockSize: number;
|
||||
blockWeight: number;
|
||||
maxFee: number;
|
||||
maxWeightFee: number;
|
||||
medianFee: number;
|
||||
minFee: number;
|
||||
minWeightFee: number;
|
||||
nTx: number;
|
||||
hasMytx: boolean;
|
||||
}
|
||||
|
||||
export interface IStrippedBlock {
|
||||
bits: number;
|
||||
difficulty: number;
|
||||
hash: string;
|
||||
height: number;
|
||||
nTx: number;
|
||||
size: number;
|
||||
strippedsize: number;
|
||||
time: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface ITransaction {
|
||||
txid: string;
|
||||
hash: string;
|
||||
version: number;
|
||||
size: number;
|
||||
vsize: number;
|
||||
locktime: number;
|
||||
vin: Vin[];
|
||||
vout: Vout[];
|
||||
hex: string;
|
||||
|
||||
fee: number;
|
||||
feePerVsize: number;
|
||||
feePerWeightUnit: number;
|
||||
}
|
||||
|
||||
export interface IBlock {
|
||||
hash: string;
|
||||
confirmations: number;
|
||||
strippedsize: number;
|
||||
size: number;
|
||||
weight: number;
|
||||
height: number;
|
||||
version: number;
|
||||
versionHex: string;
|
||||
merkleroot: string;
|
||||
tx: ITransaction[];
|
||||
time: number;
|
||||
mediantime: number;
|
||||
nonce: number;
|
||||
bits: string;
|
||||
difficulty: number;
|
||||
chainwork: string;
|
||||
nTx: number;
|
||||
previousblockhash: string;
|
||||
|
||||
minFee: number;
|
||||
maxFee: number;
|
||||
medianFee: number;
|
||||
fees: number;
|
||||
}
|
||||
|
||||
interface ScriptSig {
|
||||
asm: string;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
interface Vin {
|
||||
txid: string;
|
||||
vout: number;
|
||||
scriptSig: ScriptSig;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
interface ScriptPubKey {
|
||||
asm: string;
|
||||
hex: string;
|
||||
reqSigs: number;
|
||||
type: string;
|
||||
addresses: string[];
|
||||
}
|
||||
|
||||
interface Vout {
|
||||
value: number;
|
||||
n: number;
|
||||
scriptPubKey: ScriptPubKey;
|
||||
}
|
||||
|
||||
export interface IMempoolStats {
|
||||
id: number;
|
||||
added: string;
|
||||
unconfirmed_transactions: number;
|
||||
tx_per_second: number;
|
||||
vbytes_per_second: number;
|
||||
mempool_byte_weight: number;
|
||||
fee_data: IFeeData;
|
||||
vsize_1: number;
|
||||
vsize_2: number;
|
||||
vsize_3: number;
|
||||
vsize_4: number;
|
||||
vsize_5: number;
|
||||
vsize_6: number;
|
||||
vsize_8: number;
|
||||
vsize_10: number;
|
||||
vsize_12: number;
|
||||
vsize_15: number;
|
||||
vsize_20: number;
|
||||
vsize_30: number;
|
||||
vsize_40: number;
|
||||
vsize_50: number;
|
||||
vsize_60: number;
|
||||
vsize_70: number;
|
||||
vsize_80: number;
|
||||
vsize_90: number;
|
||||
vsize_100: number;
|
||||
vsize_125: number;
|
||||
vsize_150: number;
|
||||
vsize_175: number;
|
||||
vsize_200: number;
|
||||
vsize_250: number;
|
||||
vsize_300: number;
|
||||
vsize_350: number;
|
||||
vsize_400: number;
|
||||
vsize_500: number;
|
||||
vsize_600: number;
|
||||
vsize_700: number;
|
||||
vsize_800: number;
|
||||
vsize_900: number;
|
||||
vsize_1000: number;
|
||||
vsize_1200: number;
|
||||
vsize_1400: number;
|
||||
vsize_1600: number;
|
||||
vsize_1800: number;
|
||||
vsize_2000: number;
|
||||
}
|
||||
|
||||
export interface IBlockTransaction {
|
||||
f: number;
|
||||
fpv: number;
|
||||
}
|
||||
|
||||
interface IFeeData {
|
||||
wu: { [ fee: string ]: number };
|
||||
vsize: { [ fee: string ]: number };
|
||||
}
|
||||
18
frontend/src/app/footer/footer.component.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="my-2 my-md-0 mr-md-3">
|
||||
<div *ngIf="memPoolInfo" class="info-block">
|
||||
<span class="unconfirmedTx">Unconfirmed transactions:</span> <b>{{ memPoolInfo?.memPoolInfo?.size | number }} ({{ mempoolBlocks }} block<span [hidden]="mempoolBlocks <= 1">s</span>)</b>
|
||||
<br />
|
||||
<span class="mempoolSize">Tx per second:</span> <b>{{ memPoolInfo?.txPerSecond | number : '1.2-2' }} tx/s</b>
|
||||
<br />
|
||||
<span class="txPerSecond">Tx weight per second:</span>
|
||||
|
||||
<div class="progress">
|
||||
<div class="progress-bar {{ progressClass }}" role="progressbar" [ngStyle]="{'width': progressWidth}">{{ memPoolInfo?.vBytesPerSecond | ceil | number }} vBytes/s</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
42
frontend/src/app/footer/footer.component.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background-color: #1d1f31;
|
||||
}
|
||||
|
||||
.footer > .container {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.txPerSecond {
|
||||
color: #4a9ff4;
|
||||
}
|
||||
|
||||
.mempoolSize {
|
||||
color: #4a68b9;
|
||||
}
|
||||
|
||||
.unconfirmedTx {
|
||||
color: #f14d80;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
float:left;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: inline-flex;
|
||||
width: 150px;
|
||||
background-color: #2d3348;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.bg-warning {
|
||||
background-color: #b58800 !important;
|
||||
}
|
||||
54
frontend/src/app/footer/footer.component.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { MemPoolService, MemPoolState } from '../services/mem-pool.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrls: ['./footer.component.scss']
|
||||
})
|
||||
export class FooterComponent implements OnInit {
|
||||
memPoolInfo: MemPoolState | undefined;
|
||||
mempoolBlocks = 0;
|
||||
progressWidth = '';
|
||||
progressClass: string;
|
||||
|
||||
constructor(
|
||||
private memPoolService: MemPoolService
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.memPoolService.loaderSubject
|
||||
.subscribe((mempoolState) => {
|
||||
this.memPoolInfo = mempoolState;
|
||||
this.updateProgress();
|
||||
});
|
||||
this.memPoolService.mempoolWeight
|
||||
.subscribe((mempoolWeight) => {
|
||||
this.mempoolBlocks = Math.ceil(mempoolWeight / 4000000);
|
||||
});
|
||||
}
|
||||
|
||||
updateProgress() {
|
||||
if (!this.memPoolInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vBytesPerSecondLimit = 1667;
|
||||
|
||||
let vBytesPerSecond = this.memPoolInfo.vBytesPerSecond;
|
||||
if (vBytesPerSecond > 1667) {
|
||||
vBytesPerSecond = 1667;
|
||||
}
|
||||
|
||||
const percent = Math.round((vBytesPerSecond / vBytesPerSecondLimit) * 100);
|
||||
this.progressWidth = percent + '%';
|
||||
|
||||
if (percent <= 75) {
|
||||
this.progressClass = 'bg-success';
|
||||
} else if (percent <= 99) {
|
||||
this.progressClass = 'bg-warning';
|
||||
} else {
|
||||
this.progressClass = 'bg-danger';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Fee distribution for projected block</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div>
|
||||
<table class="table table-borderless table-sm">
|
||||
<tr>
|
||||
<th>Median fee:</th>
|
||||
<td>~{{ block.medianFee | ceil }} sat/vB <span *ngIf="conversions">(~<span class="green-color">{{ conversions.USD * (block.medianFee/100000000)*250 | currency:'USD':'symbol':'1.2-2' }}</span>/tx)</span></td>
|
||||
<th>Tx count:</th>
|
||||
<td>{{ block.nTx }} transactions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Fee span:</th>
|
||||
<td class="yellow-color">{{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total fees:</th>
|
||||
<td>{{ block.fees | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * block.fees| currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div style="height: 400px;" *ngIf="mempoolVsizeFeesData; else loadingFees">
|
||||
<app-chartist
|
||||
[data]="mempoolVsizeFeesData"
|
||||
[type]="'Bar'"
|
||||
[options]="mempoolVsizeFeesOptions">
|
||||
</app-chartist>
|
||||
</div>
|
||||
<ng-template #loadingFees>
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
.yellow-color {
|
||||
color: #ffd800;
|
||||
}
|
||||
|
||||
.green-color {
|
||||
color: #3bcc49;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Component, OnInit, Input } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { IBlock } from '../blockchain/interfaces';
|
||||
import { MemPoolService } from '../services/mem-pool.service';
|
||||
import * as Chartist from 'chartist';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projected-block-modal',
|
||||
templateUrl: './projected-block-modal.component.html',
|
||||
styleUrls: ['./projected-block-modal.component.scss']
|
||||
})
|
||||
export class ProjectedBlockModalComponent implements OnInit {
|
||||
@Input() block: IBlock;
|
||||
@Input() index: number;
|
||||
|
||||
mempoolVsizeFeesData: any;
|
||||
mempoolVsizeFeesOptions: any;
|
||||
conversions: any;
|
||||
|
||||
constructor(
|
||||
public activeModal: NgbActiveModal,
|
||||
private apiService: ApiService,
|
||||
private memPoolService: MemPoolService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
this.mempoolVsizeFeesOptions = {
|
||||
showArea: false,
|
||||
showLine: false,
|
||||
fullWidth: false,
|
||||
showPoint: false,
|
||||
low: 0,
|
||||
axisX: {
|
||||
position: 'start',
|
||||
showLabel: false,
|
||||
offset: 0,
|
||||
showGrid: false,
|
||||
},
|
||||
axisY: {
|
||||
position: 'end',
|
||||
scaleMinSpace: 40,
|
||||
showGrid: false,
|
||||
},
|
||||
plugins: [
|
||||
Chartist.plugins.tooltip({
|
||||
tooltipOffset: {
|
||||
x: 15,
|
||||
y: 250
|
||||
},
|
||||
transformTooltipTextFnc: (value: number): any => {
|
||||
return Math.ceil(value) + ' sat/vB';
|
||||
},
|
||||
anchorToPoint: false,
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
this.memPoolService.conversions
|
||||
.subscribe((conversions) => {
|
||||
this.conversions = conversions;
|
||||
});
|
||||
|
||||
this.apiService.listTransactionsForProjectedBlock$(this.index)
|
||||
.subscribe((data) => {
|
||||
this.mempoolVsizeFeesData = {
|
||||
labels: data.map((x, i) => i),
|
||||
series: [data.map((tx) => tx.fpv)]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
68
frontend/src/app/services/api.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { webSocket } from 'rxjs/webSocket';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { IMempoolDefaultResponse, IMempoolStats, IBlockTransaction } from '../blockchain/interfaces';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
|
||||
let WEB_SOCKET_URL = 'wss://mempool.space:8999';
|
||||
let API_BASE_URL = 'https://mempool.space:8999/api/v1';
|
||||
|
||||
if (!environment.production) {
|
||||
WEB_SOCKET_URL = 'ws://localhost:8999';
|
||||
API_BASE_URL = '/api/v1';
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
constructor(
|
||||
private httpClient: HttpClient,
|
||||
) { }
|
||||
|
||||
websocketSubject = webSocket<IMempoolDefaultResponse>(WEB_SOCKET_URL);
|
||||
|
||||
listTransactionsForBlock$(height: number): Observable<IBlockTransaction[]> {
|
||||
return this.httpClient.get<IBlockTransaction[]>(API_BASE_URL + '/transactions/height/' + height);
|
||||
}
|
||||
|
||||
listTransactionsForProjectedBlock$(index: number): Observable<IBlockTransaction[]> {
|
||||
return this.httpClient.get<IBlockTransaction[]>(API_BASE_URL + '/transactions/projected/' + index);
|
||||
}
|
||||
|
||||
listLiveStatistics$(lastId: number): Observable<IMempoolStats[]> {
|
||||
const params = new HttpParams()
|
||||
.set('lastId', lastId.toString());
|
||||
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/live', {
|
||||
params: params
|
||||
});
|
||||
}
|
||||
|
||||
list2HStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/2h');
|
||||
}
|
||||
|
||||
list24HStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/24h');
|
||||
}
|
||||
|
||||
list1WStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/1w');
|
||||
}
|
||||
|
||||
list1MStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/1m');
|
||||
}
|
||||
|
||||
list3MStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/3m');
|
||||
}
|
||||
|
||||
list6MStatistics$(): Observable<IMempoolStats[]> {
|
||||
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/6m');
|
||||
}
|
||||
|
||||
}
|
||||
20
frontend/src/app/services/mem-pool.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Subject, ReplaySubject } from 'rxjs';
|
||||
import { IMempoolInfo } from '../blockchain/interfaces';
|
||||
|
||||
export interface MemPoolState {
|
||||
memPoolInfo: IMempoolInfo;
|
||||
txPerSecond: number;
|
||||
vBytesPerSecond: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MemPoolService {
|
||||
loaderSubject = new Subject<MemPoolState>();
|
||||
isOffline = new Subject<boolean>();
|
||||
txIdSearch = new Subject<string>();
|
||||
conversions = new ReplaySubject<any>();
|
||||
mempoolWeight = new Subject<number>();
|
||||
}
|
||||
63
frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/* tslint:disable */
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { isNumberFinite, isPositive, isInteger, toDecimal } from './utils';
|
||||
|
||||
export type ByteUnit = 'B' | 'kB' | 'MB' | 'GB' | 'TB';
|
||||
|
||||
@Pipe({
|
||||
name: 'bytes'
|
||||
})
|
||||
export class BytesPipe implements PipeTransform {
|
||||
|
||||
static formats: { [key: string]: { max: number, prev?: ByteUnit } } = {
|
||||
'B': {max: 1000},
|
||||
'kB': {max: Math.pow(1000, 2), prev: 'B'},
|
||||
'MB': {max: Math.pow(1000, 3), prev: 'kB'},
|
||||
'GB': {max: Math.pow(1000, 4), prev: 'MB'},
|
||||
'TB': {max: Number.MAX_SAFE_INTEGER, prev: 'GB'}
|
||||
};
|
||||
|
||||
transform(input: any, decimal: number = 0, from: ByteUnit = 'B', to?: ByteUnit): any {
|
||||
|
||||
if (!(isNumberFinite(input) &&
|
||||
isNumberFinite(decimal) &&
|
||||
isInteger(decimal) &&
|
||||
isPositive(decimal))) {
|
||||
return input;
|
||||
}
|
||||
|
||||
let bytes = input;
|
||||
let unit = from;
|
||||
while (unit !== 'B') {
|
||||
bytes *= 1024;
|
||||
unit = BytesPipe.formats[unit].prev!;
|
||||
}
|
||||
|
||||
if (to) {
|
||||
const format = BytesPipe.formats[to];
|
||||
|
||||
const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal);
|
||||
|
||||
return BytesPipe.formatResult(result, to);
|
||||
}
|
||||
|
||||
for (const key in BytesPipe.formats) {
|
||||
const format = BytesPipe.formats[key];
|
||||
if (bytes < format.max) {
|
||||
|
||||
const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal);
|
||||
|
||||
return BytesPipe.formatResult(result, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static formatResult(result: number, unit: string): string {
|
||||
return `${result} ${unit}`;
|
||||
}
|
||||
|
||||
static calculateResult(format: { max: number, prev?: ByteUnit }, bytes: number) {
|
||||
const prev = format.prev ? BytesPipe.formats[format.prev] : undefined;
|
||||
return prev ? bytes / prev.max : bytes;
|
||||
}
|
||||
}
|
||||
311
frontend/src/app/shared/pipes/bytes-pipe/utils.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/* tslint:disable */
|
||||
|
||||
export type CollectionPredicate = (item?: any, index?: number, collection?: any[]) => boolean;
|
||||
|
||||
export function isUndefined(value: any): value is undefined {
|
||||
|
||||
return typeof value === 'undefined';
|
||||
}
|
||||
|
||||
export function isNull(value: any): value is null {
|
||||
return value === null;
|
||||
}
|
||||
|
||||
export function isNumber(value: any): value is number {
|
||||
return typeof value === 'number';
|
||||
}
|
||||
|
||||
export function isNumberFinite(value: any): value is number {
|
||||
return isNumber(value) && isFinite(value);
|
||||
}
|
||||
|
||||
// Not strict positive
|
||||
export function isPositive(value: number): boolean {
|
||||
return value >= 0;
|
||||
}
|
||||
|
||||
|
||||
export function isInteger(value: number): boolean {
|
||||
// No rest, is an integer
|
||||
return (value % 1) === 0;
|
||||
}
|
||||
|
||||
export function isNil(value: any): value is (null | undefined) {
|
||||
return value === null || typeof (value) === 'undefined';
|
||||
}
|
||||
|
||||
export function isString(value: any): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
export function isObject(value: any): boolean {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
export function isArray(value: any): boolean {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isFunction(value: any): boolean {
|
||||
return typeof value === 'function';
|
||||
}
|
||||
|
||||
export function toDecimal(value: number, decimal: number): number {
|
||||
return Math.round(value * Math.pow(10, decimal)) / Math.pow(10, decimal);
|
||||
}
|
||||
|
||||
export function upperFirst(value: string): string {
|
||||
return value.slice(0, 1).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
export function createRound(method: string): Function {
|
||||
// <any>Math to suppress error
|
||||
const func: any = (<any>Math)[method];
|
||||
return function (value: number, precision: number = 0) {
|
||||
if (typeof value === 'string') {
|
||||
throw new TypeError('Rounding method needs a number');
|
||||
}
|
||||
if (typeof precision !== 'number' || isNaN(precision)) {
|
||||
precision = 0;
|
||||
}
|
||||
if (precision) {
|
||||
let pair = `${value}e`.split('e');
|
||||
const val = func(`${pair[0]}e` + (+pair[1] + precision));
|
||||
pair = `${val}e`.split('e');
|
||||
return +(pair[0] + 'e' + (+pair[1] - precision));
|
||||
}
|
||||
return func(value);
|
||||
};
|
||||
}
|
||||
|
||||
export function leftPad(str: string, len: number = 0, ch: any = ' ') {
|
||||
str = String(str);
|
||||
ch = toString(ch);
|
||||
let i = -1;
|
||||
const length = len - str.length;
|
||||
while (++i < length && (str.length + ch.length) <= len) {
|
||||
str = ch + str;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function rightPad(str: string, len: number = 0, ch: any = ' ') {
|
||||
str = String(str);
|
||||
ch = toString(ch);
|
||||
let i = -1;
|
||||
const length = len - str.length;
|
||||
while (++i < length && (str.length + ch.length) <= len) {
|
||||
str += ch;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function toString(value: number | string) {
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
export function pad(str: string, len: number = 0, ch: any = ' '): string {
|
||||
str = String(str);
|
||||
ch = toString(ch);
|
||||
let i = -1;
|
||||
const length = len - str.length;
|
||||
|
||||
let left = true;
|
||||
while (++i < length) {
|
||||
const l = (str.length + ch.length <= len) ? (str.length + ch.length) : (str.length + 1);
|
||||
if (left) {
|
||||
str = leftPad(str, l, ch);
|
||||
} else {
|
||||
str = rightPad(str, l, ch);
|
||||
}
|
||||
left = !left;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function flatten(input: any[], index: number = 0): any[] {
|
||||
|
||||
if (index >= input.length) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (isArray(input[index])) {
|
||||
return flatten(
|
||||
input.slice(0, index).concat(input[index], input.slice(index + 1)),
|
||||
index
|
||||
);
|
||||
}
|
||||
|
||||
return flatten(input, index + 1);
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function getProperty(value: { [key: string]: any }, key: string): any {
|
||||
|
||||
if (isNil(value) || !isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keys: string[] = key.split('.');
|
||||
let result: any = value[keys.shift()!];
|
||||
|
||||
for (const kk of keys) {
|
||||
if (isNil(result) || !isObject(result)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
result = result[kk];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sum(input: Array<number>, initial = 0): number {
|
||||
|
||||
return input.reduce((previous: number, current: number) => previous + current, initial);
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array-in-javascript
|
||||
export function shuffle(input: any): any {
|
||||
|
||||
if (!isArray(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const copy = [...input];
|
||||
|
||||
for (let i = copy.length; i; --i) {
|
||||
const j = Math.floor(Math.random() * i);
|
||||
const x = copy[i - 1];
|
||||
copy[i - 1] = copy[j];
|
||||
copy[j] = x;
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function deepIndexOf(collection: any[], value: any) {
|
||||
|
||||
let index = -1;
|
||||
const length = collection.length;
|
||||
|
||||
while (++index < length) {
|
||||
if (deepEqual(value, collection[index])) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
export function deepEqual(a: any, b: any) {
|
||||
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(typeof a === 'object' && typeof b === 'object')) {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test for A's keys different from B.
|
||||
const hasOwn = Object.prototype.hasOwnProperty;
|
||||
for (let i = 0; i < keysA.length; i++) {
|
||||
const key = keysA[i];
|
||||
if (!hasOwn.call(b, keysA[i]) || !deepEqual(a[key], b[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isDeepObject(object: any) {
|
||||
|
||||
return object.__isDeepObject__;
|
||||
}
|
||||
|
||||
export function wrapDeep(object: any) {
|
||||
|
||||
return new DeepWrapper(object);
|
||||
}
|
||||
|
||||
export function unwrapDeep(object: any) {
|
||||
|
||||
if (isDeepObject(object)) {
|
||||
return object.data;
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
export class DeepWrapper {
|
||||
|
||||
public __isDeepObject__ = true;
|
||||
|
||||
constructor(public data: any) { }
|
||||
}
|
||||
|
||||
export function count(input: any): any {
|
||||
|
||||
if (!isArray(input) && !isObject(input) && !isString(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (isObject(input)) {
|
||||
return Object.keys(input).map((value) => input[value]).length;
|
||||
}
|
||||
|
||||
return input.length;
|
||||
}
|
||||
|
||||
export function empty(input: any): any {
|
||||
|
||||
if (!isArray(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return input.length === 0;
|
||||
}
|
||||
|
||||
export function every(input: any, predicate: CollectionPredicate) {
|
||||
|
||||
if (!isArray(input) || !predicate) {
|
||||
return input;
|
||||
}
|
||||
|
||||
let result = true;
|
||||
let i = -1;
|
||||
|
||||
while (++i < input.length && result) {
|
||||
result = predicate(input[i], i, input);
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function takeUntil(input: any[], predicate: CollectionPredicate) {
|
||||
|
||||
let i = -1;
|
||||
const result: any = [];
|
||||
while (++i < input.length && !predicate(input[i], i, input)) {
|
||||
result[i] = input[i];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function takeWhile(input: any[], predicate: CollectionPredicate) {
|
||||
return takeUntil(input, (item: any, index: number | undefined, collection: any[] | undefined) =>
|
||||
!predicate(item, index, collection));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({ name: 'ceil' })
|
||||
export class CeilPipe implements PipeTransform {
|
||||
transform(nr: number) {
|
||||
return Math.ceil(nr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({ name: 'round' })
|
||||
export class RoundPipe implements PipeTransform {
|
||||
transform(nr: number) {
|
||||
return Math.round(nr);
|
||||
}
|
||||
}
|
||||
34
frontend/src/app/shared/shared.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgbButtonsModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
import { BytesPipe } from './pipes/bytes-pipe/bytes.pipe';
|
||||
import { RoundPipe } from './pipes/math-round-pipe/math-round.pipe';
|
||||
import { CeilPipe } from './pipes/math-ceil/math-ceil.pipe';
|
||||
import { ChartistComponent } from '../statistics/chartist.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgbButtonsModule.forRoot(),
|
||||
NgbModalModule.forRoot(),
|
||||
],
|
||||
declarations: [
|
||||
ChartistComponent,
|
||||
RoundPipe,
|
||||
CeilPipe,
|
||||
BytesPipe,
|
||||
],
|
||||
exports: [
|
||||
RoundPipe,
|
||||
CeilPipe,
|
||||
BytesPipe,
|
||||
NgbButtonsModule,
|
||||
NgbModalModule,
|
||||
ChartistComponent,
|
||||
],
|
||||
providers: [
|
||||
BytesPipe
|
||||
]
|
||||
})
|
||||
export class SharedModule { }
|
||||
72
frontend/src/app/statistics/chartist.component.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
@import "../../styles.scss";
|
||||
|
||||
.ct-bar-label {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.ct-target-line {
|
||||
stroke: #f5f5f5;
|
||||
stroke-width: 3px;
|
||||
stroke-dasharray: 7px;
|
||||
}
|
||||
|
||||
.ct-area {
|
||||
stroke: none;
|
||||
fill-opacity: 0.9;
|
||||
}
|
||||
|
||||
.ct-label {
|
||||
fill: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.ct-grid {
|
||||
stroke: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* LEGEND */
|
||||
|
||||
.ct-legend {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
left: 0px;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
padding: 0px 0px 0px 30px;
|
||||
top: 90px;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 23px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
li:before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
content: '';
|
||||
border: 3px solid transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
li.inactive:before {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&.ct-legend-inside {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@for $i from 0 to length($ct-series-colors) {
|
||||
.ct-series-#{$i}:before {
|
||||
background-color: nth($ct-series-colors, $i + 1);
|
||||
border-color: nth($ct-series-colors, $i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
657
frontend/src/app/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.constructor.name === Chartist.Bar.prototype.constructor.name) {
|
||||
tooltipSelector = 'ct-bar';
|
||||
} else if (chart.constructor.name === Chartist.Pie.prototype.constructor.name) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
108
frontend/src/app/statistics/statistics.component.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<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'" disabled> 1Y
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'all'" disabled> All
|
||||
</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 class="col-lg-12">
|
||||
<div class="card mb-3" *ngIf="mempoolTransactionsPerSecondData">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-area-chart"></i> Transactions per second (tx/s)</div>
|
||||
<div class="card-body">
|
||||
<div style="height: 600px;">
|
||||
<app-chartist
|
||||
[data]="mempoolTransactionsPerSecondData"
|
||||
[type]="'Line'"
|
||||
[options]="transactionsPerSecondOptions">
|
||||
</app-chartist>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
16
frontend/src/app/statistics/statistics.component.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.card-header {
|
||||
border-bottom: 0;
|
||||
background-color: none;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.bootstrap-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
274
frontend/src/app/statistics/statistics.component.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { Component, OnInit, LOCALE_ID, Inject } from '@angular/core';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { formatDate } from '@angular/common';
|
||||
import { BytesPipe } from '../shared/pipes/bytes-pipe/bytes.pipe';
|
||||
|
||||
import * as Chartist from 'chartist';
|
||||
import { FormGroup, FormBuilder } from '@angular/forms';
|
||||
import { IMempoolStats } from '../blockchain/interfaces';
|
||||
import { Subject, of, merge} from 'rxjs';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-statistics',
|
||||
templateUrl: './statistics.component.html',
|
||||
styleUrls: ['./statistics.component.scss']
|
||||
})
|
||||
export class StatisticsComponent implements OnInit {
|
||||
loading = true;
|
||||
spinnerLoading = false;
|
||||
|
||||
mempoolStats: IMempoolStats[] = [];
|
||||
|
||||
mempoolVsizeFeesData: any;
|
||||
mempoolUnconfirmedTransactionsData: any;
|
||||
mempoolTransactionsPerSecondData: any;
|
||||
mempoolTransactionsWeightPerSecondData: any;
|
||||
|
||||
mempoolVsizeFeesOptions: any;
|
||||
transactionsPerSecondOptions: any;
|
||||
transactionsWeightPerSecondOptions: any;
|
||||
|
||||
radioGroupForm: FormGroup;
|
||||
|
||||
reloadData$: Subject<any> = new Subject();
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
@Inject(LOCALE_ID) private locale: string,
|
||||
private bytesPipe: BytesPipe,
|
||||
private formBuilder: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
) {
|
||||
this.radioGroupForm = this.formBuilder.group({
|
||||
'dateSpan': '2h'
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const now = new Date();
|
||||
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
|
||||
Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0);
|
||||
const difference = nextInterval.getTime() - now.getTime();
|
||||
|
||||
setTimeout(() => {
|
||||
setInterval(() => {
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '2h') {
|
||||
this.reloadData$.next();
|
||||
}
|
||||
}, 60 * 1000);
|
||||
}, difference + 1000); // Next whole minute + 1 second
|
||||
|
||||
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.bytesPipe.transform(value);
|
||||
},
|
||||
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.transactionsPerSecondOptions = {
|
||||
showArea: false,
|
||||
showLine: true,
|
||||
showPoint: false,
|
||||
low: 0,
|
||||
axisY: {
|
||||
offset: 40
|
||||
},
|
||||
axisX: {
|
||||
labelInterpolationFnc: labelInterpolationFnc
|
||||
},
|
||||
};
|
||||
|
||||
this.route
|
||||
.fragment
|
||||
.subscribe((fragment) => {
|
||||
if (['2h', '24h', '1w', '1m', '3m', '6m'].indexOf(fragment) > -1) {
|
||||
this.radioGroupForm.controls['dateSpan'].setValue(fragment);
|
||||
}
|
||||
});
|
||||
|
||||
merge(
|
||||
of(''),
|
||||
this.reloadData$,
|
||||
this.radioGroupForm.controls['dateSpan'].valueChanges
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.mempoolStats = [];
|
||||
})
|
||||
)
|
||||
)
|
||||
.pipe(
|
||||
switchMap(() => {
|
||||
this.spinnerLoading = true;
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '6m') {
|
||||
return this.apiService.list6MStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '3m') {
|
||||
return this.apiService.list3MStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '1m') {
|
||||
return this.apiService.list1MStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '1w') {
|
||||
return this.apiService.list1WStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '24h') {
|
||||
return this.apiService.list24HStatistics$();
|
||||
}
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '2h' && !this.mempoolStats.length) {
|
||||
return this.apiService.list2HStatistics$();
|
||||
}
|
||||
const lastId = this.mempoolStats[0].id;
|
||||
return this.apiService.listLiveStatistics$(lastId);
|
||||
})
|
||||
)
|
||||
.subscribe((mempoolStats) => {
|
||||
let hasChange = false;
|
||||
if (this.radioGroupForm.controls['dateSpan'].value === '2h' && this.mempoolStats.length) {
|
||||
if (mempoolStats.length) {
|
||||
this.mempoolStats = mempoolStats.concat(this.mempoolStats);
|
||||
this.mempoolStats = this.mempoolStats.slice(0, this.mempoolStats.length - mempoolStats.length);
|
||||
hasChange = true;
|
||||
}
|
||||
} else {
|
||||
this.mempoolStats = mempoolStats;
|
||||
hasChange = true;
|
||||
}
|
||||
if (hasChange) {
|
||||
this.handleNewMempoolData(this.mempoolStats.concat([]));
|
||||
}
|
||||
this.loading = false;
|
||||
this.spinnerLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
handleNewMempoolData(mempoolStats: IMempoolStats[]) {
|
||||
mempoolStats.reverse();
|
||||
const labels = mempoolStats.map(stats => stats.added);
|
||||
|
||||
/** Active admins summed up */
|
||||
|
||||
this.mempoolTransactionsPerSecondData = {
|
||||
labels: labels,
|
||||
series: [mempoolStats.map((stats) => stats.tx_per_second)],
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
getTimeToNextTenMinutes(): number {
|
||||
const now = new Date();
|
||||
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
|
||||
Math.floor(now.getMinutes() / 10) * 10 + 10, 0, 0);
|
||||
return nextInterval.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
generateArray(mempoolStats: IMempoolStats[]) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
26
frontend/src/app/tx-bubble/tx-bubble.component.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<div class="txBubble" *ngIf="tx">
|
||||
<span class="txBubbleText" ngClass="arrow-{{ arrowPosition }}">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td class="text-left"><b>Transaction hash</b></td>
|
||||
<td class="text-right"><a href="https://www.blockstream.info/tx/{{ tx?.txid }}" target="_blank">{{ txIdShort }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-left"><b>Fees:</b></td>
|
||||
<td class="text-right">{{ tx?.fee }} BTC</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-left"><b>Fee per vByte:</b></td>
|
||||
<td class="text-right">{{ tx?.feePerVsize | number : '1.2-2' }} sat/vB</td>
|
||||
</tr>
|
||||
</table>
|
||||
<br />
|
||||
|
||||
<span *ngIf="txTrackingBlockHeight === 0">
|
||||
<button type="button" class="btn btn-danger">Unconfirmed</button>
|
||||
</span>
|
||||
<span *ngIf="txTrackingBlockHeight > 0">
|
||||
<button type="button" class="btn btn-success">{{ confirmations }} confirmation<span *ngIf="confirmations > 1">s</span></button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
65
frontend/src/app/tx-bubble/tx-bubble.component.scss
Normal file
@@ -0,0 +1,65 @@
|
||||
.txBubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-bottom: 1px dotted #000000;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.txBubble .txBubbleText {
|
||||
width: 300px;
|
||||
background-color: #ffffff;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 0;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 150%;
|
||||
left: 50%;
|
||||
margin-left: -100px;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.txBubble .txBubbleText::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
border-width: 10px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent white transparent;
|
||||
}
|
||||
|
||||
.txBubble .arrow-right.txBubbleText::after {
|
||||
top: calc(50% - 10px);
|
||||
border-color: transparent transparent transparent white;
|
||||
right: -20px;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.txBubble .arrow-left.txBubbleText::after {
|
||||
top: calc(50% - 10px);
|
||||
left: 0;
|
||||
margin-left: -20px;
|
||||
border-width: 10px;
|
||||
border-color: transparent white transparent transparent;
|
||||
}
|
||||
|
||||
.txBubble .arrow-bottom.txBubbleText::after {
|
||||
bottom: -20px;
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
border-width: 10px;
|
||||
border-style: solid;
|
||||
border-color: white transparent transparent transparent;
|
||||
}
|
||||
|
||||
.txBubble .arrow-top-right.txBubbleText::after {
|
||||
left: 80%;
|
||||
}
|
||||
|
||||
.txBubble .arrow-top-left.txBubbleText::after {
|
||||
left: 20%;
|
||||
}
|
||||
26
frontend/src/app/tx-bubble/tx-bubble.component.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Component, OnInit, Input, OnChanges } from '@angular/core';
|
||||
import { ITransaction } from '../blockchain/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tx-bubble',
|
||||
templateUrl: './tx-bubble.component.html',
|
||||
styleUrls: ['./tx-bubble.component.scss']
|
||||
})
|
||||
export class TxBubbleComponent implements OnChanges {
|
||||
@Input() tx: ITransaction | null = null;
|
||||
@Input() txTrackingBlockHeight = 0;
|
||||
@Input() latestBlockHeight = 0;
|
||||
@Input() arrowPosition: 'top' | 'right' | 'bottom' | 'top-right' | 'top-left' = 'top';
|
||||
|
||||
txIdShort = '';
|
||||
confirmations = 0;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnChanges() {
|
||||
if (this.tx) {
|
||||
this.txIdShort = this.tx.txid.substring(0, 6) + '...' + this.tx.txid.substring(this.tx.txid.length - 6);
|
||||
}
|
||||
this.confirmations = (this.latestBlockHeight - this.txTrackingBlockHeight) + 1;
|
||||
}
|
||||
}
|
||||
0
frontend/src/assets/.gitkeep
Normal file
BIN
frontend/src/assets/btc-qr-code-segwit.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/src/assets/btc-qr-code.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/src/assets/divider-new.png
Normal file
|
After Width: | Height: | Size: 81 B |
BIN
frontend/src/assets/favicon/android-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/android-icon-192x192.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
frontend/src/assets/favicon/android-icon-36x36.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/src/assets/favicon/android-icon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/src/assets/favicon/android-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/src/assets/favicon/android-icon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/src/assets/favicon/apple-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
frontend/src/assets/favicon/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/src/assets/favicon/apple-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/src/assets/favicon/apple-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/src/assets/favicon/apple-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/src/assets/favicon/apple-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/src/assets/favicon/apple-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/src/assets/favicon/apple-icon-precomposed.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/src/assets/favicon/apple-icon.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
2
frontend/src/assets/favicon/browserconfig.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
||||
BIN
frontend/src/assets/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/src/assets/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/src/assets/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/src/assets/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
41
frontend/src/assets/favicon/manifest.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
frontend/src/assets/favicon/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/ms-icon-150x150.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/favicon/ms-icon-310x310.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/src/assets/favicon/ms-icon-70x70.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/src/assets/mempool-space-logo.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
frontend/src/assets/mempool-tube.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/src/assets/paynym-code.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
9
frontend/src/browserslist
Normal file
@@ -0,0 +1,9 @@
|
||||
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
# For IE 9-11 support, please uncomment the last line of the file and adjust as needed
|
||||
> 0.5%
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
# IE 9-11
|
||||
3
frontend/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
};
|
||||
15
frontend/src/environments/environment.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false
|
||||
};
|
||||
|
||||
/*
|
||||
* In development mode, to ignore zone related error stack frames such as
|
||||
* `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can
|
||||
* import the following file, but please comment it out in production mode
|
||||
* because it will have performance impact when throw error
|
||||
*/
|
||||
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
|
||||
BIN
frontend/src/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
35
frontend/src/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>mempool.space - Bitcoin mempool visualizer</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="description" content="Mempool visualizer for the Bitcoin blockchain."/>
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/assets/favicon/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/assets/favicon/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/assets/favicon/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/assets/favicon/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/assets/favicon/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/assets/favicon/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/assets/favicon/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/assets/favicon/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/assets/favicon/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/assets/favicon/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="/assets/favicon/manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-129923977-1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-129923977-1');
|
||||
</script>
|
||||
</html>
|
||||
31
frontend/src/karma.conf.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage-istanbul-reporter'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
coverageIstanbulReporter: {
|
||||
dir: require('path').join(__dirname, '../coverage'),
|
||||
reports: ['html', 'lcovonly'],
|
||||
fixWebpackSourcePaths: true
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false
|
||||
});
|
||||
};
|
||||
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { enableProdMode } from '@angular/core';
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode();
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err));
|
||||
80
frontend/src/polyfills.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
|
||||
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
|
||||
// import 'core-js/es6/symbol';
|
||||
// import 'core-js/es6/object';
|
||||
// import 'core-js/es6/function';
|
||||
// import 'core-js/es6/parse-int';
|
||||
// import 'core-js/es6/parse-float';
|
||||
// import 'core-js/es6/number';
|
||||
// import 'core-js/es6/math';
|
||||
// import 'core-js/es6/string';
|
||||
// import 'core-js/es6/date';
|
||||
// import 'core-js/es6/array';
|
||||
// import 'core-js/es6/regexp';
|
||||
// import 'core-js/es6/map';
|
||||
// import 'core-js/es6/weak-map';
|
||||
// import 'core-js/es6/set';
|
||||
|
||||
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
|
||||
// import 'classlist.js'; // Run `npm install --save classlist.js`.
|
||||
|
||||
/** IE10 and IE11 requires the following for the Reflect API. */
|
||||
// import 'core-js/es6/reflect';
|
||||
|
||||
|
||||
/** Evergreen browsers require these. **/
|
||||
// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove.
|
||||
import 'core-js/es7/reflect';
|
||||
|
||||
|
||||
/**
|
||||
* Web Animations `@angular/platform-browser/animations`
|
||||
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
|
||||
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
|
||||
**/
|
||||
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
*/
|
||||
|
||||
// (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
// (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
// (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
|
||||
/*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*/
|
||||
// (window as any).__Zone_enable_cross_context_check = true;
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js/dist/zone'; // Included with Angular CLI.
|
||||
|
||||
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
131
frontend/src/styles.scss
Normal file
@@ -0,0 +1,131 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
|
||||
$ct-series-names: (a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z);
|
||||
$ct-series-colors: (
|
||||
#D81B60,
|
||||
#8E24AA,
|
||||
#5E35B1,
|
||||
#3949AB,
|
||||
#1E88E5,
|
||||
#039BE5,
|
||||
#00ACC1,
|
||||
#00897B,
|
||||
#43A047,
|
||||
#7CB342,
|
||||
#C0CA33,
|
||||
#FDD835,
|
||||
#FFB300,
|
||||
#FB8C00,
|
||||
#F4511E,
|
||||
#6D4C41,
|
||||
#757575,
|
||||
#546E7A,
|
||||
#b71c1c,
|
||||
#880E4F,
|
||||
#4A148C,
|
||||
#311B92,
|
||||
#1A237E,
|
||||
#0D47A1,
|
||||
#01579B,
|
||||
#006064,
|
||||
#004D40,
|
||||
#1B5E20,
|
||||
#33691E,
|
||||
#827717,
|
||||
#F57F17,
|
||||
#FF6F00,
|
||||
#E65100,
|
||||
#BF360C,
|
||||
#3E2723,
|
||||
#212121,
|
||||
#263238,
|
||||
#a748ca,
|
||||
#6188e2,
|
||||
#a748ca,
|
||||
#6188e2,
|
||||
|
||||
);
|
||||
|
||||
|
||||
$body-bg: #11131f;
|
||||
$body-color: #fff;
|
||||
$gray-800: #1d1f31;
|
||||
|
||||
$primary: #2b89c7;
|
||||
|
||||
$link-color: #1bd8f4;
|
||||
$link-decoration: none !default;
|
||||
$link-hover-color: darken($link-color, 15%) !default;
|
||||
$link-hover-decoration: underline !default;
|
||||
|
||||
// Required
|
||||
@import "../node_modules/bootstrap/scss/bootstrap";
|
||||
@import "../node_modules/chartist/dist/scss/chartist.scss";
|
||||
|
||||
body {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
body.disable-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ng-invalid.ng-dirty {
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #11131f;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
.chartist-tooltip {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
min-width: 5em;
|
||||
padding: .5em;
|
||||
background: #F4C63D;
|
||||
color: #453D3F;
|
||||
font-family: Oxygen,Helvetica,Arial,sans-serif;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
-webkit-transition: opacity .2s linear;
|
||||
-moz-transition: opacity .2s linear;
|
||||
-o-transition: opacity .2s linear;
|
||||
transition: opacity .2s linear; }
|
||||
.chartist-tooltip:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: -15px;
|
||||
border: 15px solid transparent;
|
||||
border-top-color: #F4C63D; }
|
||||
.chartist-tooltip.tooltip-show {
|
||||
opacity: 1; }
|
||||
|
||||
.ct-area, .ct-line {
|
||||
pointer-events: none; }
|
||||
|
||||
.ct-bar {
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
20
frontend/src/test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
||||
|
||||
import 'zone.js/dist/zone-testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
declare const require: any;
|
||||
|
||||
// First, initialize the Angular testing environment.
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting()
|
||||
);
|
||||
// Then we find all the tests.
|
||||
const context = require.context('./', true, /\.spec\.ts$/);
|
||||
// And load the modules.
|
||||
context.keys().map(context);
|
||||
12
frontend/src/tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/app",
|
||||
"module": "es2015",
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"src/test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
19
frontend/src/tsconfig.spec.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/spec",
|
||||
"module": "commonjs",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"test.ts",
|
||||
"polyfills.ts"
|
||||
],
|
||||
"include": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
17
frontend/src/tslint.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../tslint.json",
|
||||
"rules": {
|
||||
"directive-selector": [
|
||||
true,
|
||||
"attribute",
|
||||
"app",
|
||||
"camelCase"
|
||||
],
|
||||
"component-selector": [
|
||||
true,
|
||||
"element",
|
||||
"app",
|
||||
"kebab-case"
|
||||
]
|
||||
}
|
||||
}
|
||||
22
frontend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"declaration": false,
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"target": "es5",
|
||||
"typeRoots": [
|
||||
"node_modules/@types"
|
||||
],
|
||||
"lib": [
|
||||
"es2018",
|
||||
"dom"
|
||||
]
|
||||
}
|
||||
}
|
||||
130
frontend/tslint.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"rulesDirectory": [
|
||||
"node_modules/codelyzer"
|
||||
],
|
||||
"rules": {
|
||||
"arrow-return-shorthand": true,
|
||||
"callable-types": true,
|
||||
"class-name": true,
|
||||
"comment-format": [
|
||||
true,
|
||||
"check-space"
|
||||
],
|
||||
"curly": true,
|
||||
"deprecation": {
|
||||
"severity": "warn"
|
||||
},
|
||||
"eofline": true,
|
||||
"forin": true,
|
||||
"import-blacklist": [
|
||||
true,
|
||||
"rxjs/Rx"
|
||||
],
|
||||
"import-spacing": true,
|
||||
"indent": [
|
||||
true,
|
||||
"spaces"
|
||||
],
|
||||
"interface-over-type-literal": true,
|
||||
"label-position": true,
|
||||
"max-line-length": [
|
||||
true,
|
||||
140
|
||||
],
|
||||
"member-access": false,
|
||||
"member-ordering": [
|
||||
true,
|
||||
{
|
||||
"order": [
|
||||
"static-field",
|
||||
"instance-field",
|
||||
"static-method",
|
||||
"instance-method"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-arg": true,
|
||||
"no-bitwise": true,
|
||||
"no-console": [
|
||||
true,
|
||||
"debug",
|
||||
"info",
|
||||
"time",
|
||||
"timeEnd",
|
||||
"trace"
|
||||
],
|
||||
"no-construct": true,
|
||||
"no-debugger": true,
|
||||
"no-duplicate-super": true,
|
||||
"no-empty": false,
|
||||
"no-empty-interface": true,
|
||||
"no-eval": true,
|
||||
"no-inferrable-types": [
|
||||
true,
|
||||
"ignore-params"
|
||||
],
|
||||
"no-misused-new": true,
|
||||
"no-non-null-assertion": true,
|
||||
"no-shadowed-variable": true,
|
||||
"no-string-literal": false,
|
||||
"no-string-throw": true,
|
||||
"no-switch-case-fall-through": true,
|
||||
"no-trailing-whitespace": true,
|
||||
"no-unnecessary-initializer": true,
|
||||
"no-unused-expression": true,
|
||||
"no-use-before-declare": true,
|
||||
"no-var-keyword": true,
|
||||
"object-literal-sort-keys": false,
|
||||
"one-line": [
|
||||
true,
|
||||
"check-open-brace",
|
||||
"check-catch",
|
||||
"check-else",
|
||||
"check-whitespace"
|
||||
],
|
||||
"prefer-const": true,
|
||||
"quotemark": [
|
||||
true,
|
||||
"single"
|
||||
],
|
||||
"radix": true,
|
||||
"semicolon": [
|
||||
true,
|
||||
"always"
|
||||
],
|
||||
"triple-equals": [
|
||||
true,
|
||||
"allow-null-check"
|
||||
],
|
||||
"typedef-whitespace": [
|
||||
true,
|
||||
{
|
||||
"call-signature": "nospace",
|
||||
"index-signature": "nospace",
|
||||
"parameter": "nospace",
|
||||
"property-declaration": "nospace",
|
||||
"variable-declaration": "nospace"
|
||||
}
|
||||
],
|
||||
"unified-signatures": true,
|
||||
"variable-name": false,
|
||||
"whitespace": [
|
||||
true,
|
||||
"check-branch",
|
||||
"check-decl",
|
||||
"check-operator",
|
||||
"check-separator",
|
||||
"check-type"
|
||||
],
|
||||
"no-output-on-prefix": true,
|
||||
"use-input-property-decorator": true,
|
||||
"use-output-property-decorator": true,
|
||||
"use-host-property-decorator": true,
|
||||
"no-input-rename": true,
|
||||
"no-output-rename": true,
|
||||
"use-life-cycle-interface": true,
|
||||
"use-pipe-transform-interface": true,
|
||||
"component-class-suffix": true,
|
||||
"directive-class-suffix": true
|
||||
}
|
||||
}
|
||||