widgetify block fee rates chart

This commit is contained in:
Mononaut 2023-07-20 16:25:09 +09:00
parent 4169e1053f
commit 42a3a380d5
No known key found for this signature in database
GPG Key ID: A3F058E41374C04E
4 changed files with 176 additions and 37 deletions

View File

@ -1,13 +1,13 @@
<app-indexing-progress></app-indexing-progress>
<app-indexing-progress *ngIf="!widget"></app-indexing-progress>
<div class="full-container">
<div class="card-header mb-0 mb-md-4">
<div [class.full-container]="!widget">
<div *ngIf="!widget" class="card-header mb-0 mb-md-4">
<div class="d-flex d-md-block align-items-baseline">
<span i18n="mining.block-fee-rates">Block Fee Rates</span>
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
</button>
</div>
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
@ -45,11 +45,45 @@
</form>
</div>
<div class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
<div *ngIf="widget">
<div class="block-fee-rates" *ngIf="(statsObservable$ | async) as stats; else loadingStats">
<div class="item">
<h5 class="card-title" i18n="mining.avg-block-fee-1m">Avg Block Fee (1m)</h5>
<p class="card-text">
<app-fee-rate [fee]="stats.avgMedianRate"></app-fee-rate>
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="block.???">???</h5>
<p class="card-text">
???
</p>
</div>
</div>
</div>
<div [class.chart]="!widget" [class.chart-widget]="widget" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
</div>
</div>
<ng-template #loadingStats>
<div class="block-fee-rates">
<div class="item">
<h5 class="card-title" i18n="mining.avg-block-fee">Avg Block Fee</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="block.???">???</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
</div>
</ng-template>

View File

@ -57,7 +57,54 @@
.chart-widget {
width: 100%;
height: 100%;
max-height: 270px;
max-height: 238px;
}
.block-fee-rates {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
flex-direction: row;
}
h5 {
margin-bottom: 10px;
}
.item {
width: 50%;
display: inline-block;
margin: 0px auto 20px;
&:nth-child(2) {
order: 2;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
.card-title {
font-size: 1rem;
color: #4a68b9;
}
.card-text {
font-size: 18px;
span {
color: #ffffff66;
font-size: 12px;
}
}
}
}
.formRadioGroup {
@ -85,6 +132,13 @@
}
}
.skeleton-loader {
width: 100%;
display: block;
max-width: 80px;
margin: 15px auto 3px;
}
.disabled {
pointer-events: none;
opacity: 0.5;

View File

@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, NgZone, OnInit } from '@angular/core';
import { EChartsOption } from '../../graphs/echarts';
import { Observable, Subscription, combineLatest } from 'rxjs';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnInit } from '@angular/core';
import { EChartsOption, graphic } from 'echarts';
import { Observable, Subscription, combineLatest, of } from 'rxjs';
import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service';
@ -29,6 +29,7 @@ import { ActivatedRoute, Router } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlockFeeRatesGraphComponent implements OnInit {
@Input() widget = false;
@Input() right: number | string = 45;
@Input() left: number | string = 75;
@ -57,39 +58,48 @@ export class BlockFeeRatesGraphComponent implements OnInit {
private router: Router,
private zone: NgZone,
private route: ActivatedRoute,
private cd: ChangeDetectorRef,
) {
this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' });
this.radioGroupForm.controls.dateSpan.setValue('1y');
}
ngOnInit(): void {
this.seoService.setTitle($localize`:@@ed8e33059967f554ff06b4f5b6049c465b92d9b3:Block Fee Rates`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fee-rates:See Bitcoin feerates visualized over time, including minimum and maximum feerates per block along with feerates at various percentiles.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
if (this.widget) {
this.miningWindowPreference = '1m';
} else {
this.seoService.setTitle($localize`:@@ed8e33059967f554ff06b4f5b6049c465b92d9b3:Block Fee Rates`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fee-rates:See Bitcoin feerates visualized over time, including minimum and maximum feerates per block along with feerates at various percentiles.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
}
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
this.route
.fragment
.subscribe((fragment) => {
if (['24h', '3d', '1w', '1m', '3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) {
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
}
});
if (!this.widget) {
this.route
.fragment
.subscribe((fragment) => {
if (['24h', '3d', '1w', '1m', '3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) {
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
}
});
}
this.statsObservable$ = combineLatest([
this.radioGroupForm.get('dateSpan').valueChanges.pipe(startWith(this.radioGroupForm.controls.dateSpan.value)),
this.widget ? of(this.miningWindowPreference) : this.radioGroupForm.get('dateSpan').valueChanges.pipe(startWith(this.radioGroupForm.controls.dateSpan.value)),
this.stateService.rateUnits$
]).pipe(
switchMap(([timespan, rateUnits]) => {
this.storageService.setValue('miningWindowPreference', timespan);
if (!this.widget) {
this.storageService.setValue('miningWindowPreference', timespan);
}
this.timespan = timespan;
this.isLoading = true;
return this.apiService.getHistoricalBlockFeeRates$(timespan)
.pipe(
tap((response) => {
// Group by percentile
const seriesData = {
const seriesData = this.widget ? { 'Median': [] } : {
'Min': [],
'10th': [],
'25th': [],
@ -100,13 +110,17 @@ export class BlockFeeRatesGraphComponent implements OnInit {
};
for (const rate of response.body) {
const timestamp = rate.timestamp * 1000;
seriesData['Min'].push([timestamp, rate.avgFee_0, rate.avgHeight]);
seriesData['10th'].push([timestamp, rate.avgFee_10, rate.avgHeight]);
seriesData['25th'].push([timestamp, rate.avgFee_25, rate.avgHeight]);
seriesData['Median'].push([timestamp, rate.avgFee_50, rate.avgHeight]);
seriesData['75th'].push([timestamp, rate.avgFee_75, rate.avgHeight]);
seriesData['90th'].push([timestamp, rate.avgFee_90, rate.avgHeight]);
seriesData['Max'].push([timestamp, rate.avgFee_100, rate.avgHeight]);
if (this.widget) {
seriesData['Median'].push([timestamp, rate.avgFee_50, rate.avgHeight]);
} else {
seriesData['Min'].push([timestamp, rate.avgFee_0, rate.avgHeight]);
seriesData['10th'].push([timestamp, rate.avgFee_10, rate.avgHeight]);
seriesData['25th'].push([timestamp, rate.avgFee_25, rate.avgHeight]);
seriesData['Median'].push([timestamp, rate.avgFee_50, rate.avgHeight]);
seriesData['75th'].push([timestamp, rate.avgFee_75, rate.avgHeight]);
seriesData['90th'].push([timestamp, rate.avgFee_90, rate.avgHeight]);
seriesData['Max'].push([timestamp, rate.avgFee_100, rate.avgHeight]);
}
}
// Prepare chart
@ -135,15 +149,42 @@ export class BlockFeeRatesGraphComponent implements OnInit {
});
}
if (this.widget) {
let maResolution = 30;
const medianMa = [];
for (let i = maResolution - 1; i < seriesData['Median'].length; ++i) {
let avg = 0;
for (let y = maResolution - 1; y >= 0; --y) {
avg += seriesData['Median'][i - y][1];
}
avg /= maResolution;
medianMa.push([seriesData['Median'][i][0], avg]);
}
series.push({
zlevel: 1,
name: 'MA',
data: medianMa,
type: 'line',
showSymbol: false,
symbol: 'none',
lineStyle: {
width: 3,
}
});
}
this.prepareChartOptions({
legends: legends,
series: series
}, rateUnits === 'wu');
this.isLoading = false;
this.cd.markForCheck();
}),
map((response) => {
return {
blockCount: parseInt(response.headers.get('x-total-count'), 10),
avgMedianRate: response.body.length ? response.body.reduce((acc, rate) => acc + rate.avgFee_50, 0) / response.body.length : 0,
};
}),
);
@ -154,16 +195,22 @@ export class BlockFeeRatesGraphComponent implements OnInit {
prepareChartOptions(data, weightMode) {
this.chartOptions = {
color: ['#D81B60', '#8E24AA', '#1E88E5', '#7CB342', '#FDD835', '#6D4C41', '#546E7A'],
color: this.widget ? ['#6b6b6b', new graphic.LinearGradient(0, 0, 0, 0.65, [
{ offset: 0, color: '#F4511E' },
{ offset: 0.25, color: '#FB8C00' },
{ offset: 0.5, color: '#FFB300' },
{ offset: 0.75, color: '#FDD835' },
{ offset: 1, color: '#7CB342' }
])] : ['#D81B60', '#8E24AA', '#1E88E5', '#7CB342', '#FDD835', '#6D4C41', '#546E7A'],
animation: false,
grid: {
right: this.right,
left: this.left,
bottom: 80,
top: this.isMobile() ? 10 : 50,
bottom: this.widget ? 30 : 80,
top: this.widget ? 20 : (this.isMobile() ? 10 : 50),
},
tooltip: {
show: !this.isMobile(),
show: !this.isMobile() && !this.widget,
trigger: 'axis',
axisPointer: {
type: 'line'
@ -201,7 +248,7 @@ export class BlockFeeRatesGraphComponent implements OnInit {
},
xAxis: data.series.length === 0 ? undefined :
{
name: formatterXAxisLabel(this.locale, this.timespan),
name: this.widget ? undefined : formatterXAxisLabel(this.locale, this.timespan),
nameLocation: 'middle',
nameTextStyle: {
padding: [10, 0, 0, 0],
@ -218,7 +265,7 @@ export class BlockFeeRatesGraphComponent implements OnInit {
padding: [0, 5],
},
},
legend: (data.series.length === 0) ? undefined : {
legend: (this.widget || data.series.length === 0) ? undefined : {
padding: [10, 75],
data: data.legends,
selected: JSON.parse(this.storageService.getValue('fee_rates_legend')) ?? {
@ -256,7 +303,7 @@ export class BlockFeeRatesGraphComponent implements OnInit {
max: (val) => this.timespan === 'all' ? Math.min(val.max, 5000) : undefined,
},
series: data.series,
dataZoom: [{
dataZoom: this.widget ? null : [{
type: 'inside',
realtime: true,
zoomLock: true,

View File

@ -1,7 +1,11 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
<<<<<<< HEAD
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree, BlockAudit, Acceleration, AccelerationHistoryParams } from '../interfaces/node-api.interface';
=======
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree, BlockAudit, Acceleration } from '../interfaces/node-api.interface';
>>>>>>> 9b9adcd43 (widgetify block fee rates chart)
import { BehaviorSubject, Observable, catchError, filter, of, shareReplay, take, tap } from 'rxjs';
import { StateService } from './state.service';
import { IBackendInfo, WebsocketResponse } from '../interfaces/websocket.interface';