From 7019e6ec0376b877a0b9480b0ea134b67b8d7440 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 22 Jul 2023 15:43:52 +0900 Subject: [PATCH] Add acceleration fees graph --- .../acceleration-fees-graph.component.html | 71 ++++ .../acceleration-fees-graph.component.scss | 119 ++++++ .../acceleration-fees-graph.component.ts | 354 ++++++++++++++++++ 3 files changed, 544 insertions(+) create mode 100644 frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.html create mode 100644 frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.scss create mode 100644 frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.ts diff --git a/frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.html b/frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.html new file mode 100644 index 000000000..2cf3fbc0b --- /dev/null +++ b/frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.html @@ -0,0 +1,71 @@ + + +
+
+
+ Acceleration Fees + +
+ +
+
+ + + + +
+
+
+ +
+
+
+
???
+

+ ??? +

+
+
+
???
+

+ ??? +

+
+
+
+ +
+
+
+
+
+ +
+ + +
+
+
???
+

+ +

+
+
+
???
+

+ +

+
+
+
diff --git a/frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.scss b/frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.scss new file mode 100644 index 000000000..2ffcc6374 --- /dev/null +++ b/frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.scss @@ -0,0 +1,119 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } + @media (min-width: 992px) { + height: 40px; + } +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + display: flex; + flex-direction: column; + padding: 0px 15px; + width: 100%; + height: calc(100vh - 250px); + @media (min-width: 992px) { + height: calc(100vh - 150px); + } +} + +.chart { + display: flex; + flex: 1; + width: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} +.chart-widget { + width: 100%; + height: 100%; + max-height: 238px; +} + +.acceleration-fees { + 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; + } + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} + +.disabled { + pointer-events: none; + opacity: 0.5; +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.ts b/frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.ts new file mode 100644 index 000000000..f666c8f31 --- /dev/null +++ b/frontend/src/app/components/acceleration-fees-graph/acceleration-fees-graph.component.ts @@ -0,0 +1,354 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { EChartsOption, graphic } from 'echarts'; +import { Observable, combineLatest } from 'rxjs'; +import { map, startWith, switchMap, tap } from 'rxjs/operators'; +import { ApiService } from '../../services/api.service'; +import { SeoService } from '../../services/seo.service'; +import { formatNumber } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { download, formatterXAxis } from '../../shared/graphs.utils'; +import { StorageService } from '../../services/storage.service'; +import { MiningService } from '../../services/mining.service'; +import { ActivatedRoute } from '@angular/router'; + +@Component({ + selector: 'app-acceleration-fees-graph', + templateUrl: './acceleration-fees-graph.component.html', + styleUrls: ['./acceleration-fees-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccelerationFeesGraphComponent implements OnInit { + @Input() widget: boolean = false; + @Input() right: number | string = 45; + @Input() left: number | string = 75; + + miningWindowPreference: string; + radioGroupForm: UntypedFormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + statsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + + currency: string; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private apiService: ApiService, + private formBuilder: UntypedFormBuilder, + private storageService: StorageService, + private miningService: MiningService, + private route: ActivatedRoute, + private cd: ChangeDetectorRef, + ) { + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); + this.radioGroupForm.controls.dateSpan.setValue('1y'); + this.currency = 'USD'; + } + + ngOnInit(): void { + this.seoService.setTitle($localize`:@@6c453b11fd7bd159ae30bc381f367bc736d86909:Acceleration Fees`); + if (this.widget) { + this.miningWindowPreference = '1w'; + this.isLoading = true; + this.timespan = this.miningWindowPreference; + this.statsObservable$ = combineLatest([ + this.apiService.getAccelerationHistory$(this.miningWindowPreference), + this.apiService.getHistoricalBlockFees$(this.miningWindowPreference), + ]).pipe( + tap(([accelerations, blockFeesResponse]) => { + console.log(accelerations, blockFeesResponse.body); + this.prepareChartOptions(accelerations, blockFeesResponse.body); + this.isLoading = false; + }), + map(([accelerations, blockFeesResponse]) => { + return { + + }; + }), + ); + } else { + this.miningWindowPreference = this.miningService.getDefaultTimespan('1w'); + 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'].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), + switchMap((timespan) => { + this.isLoading = true; + this.storageService.setValue('miningWindowPreference', timespan); + this.timespan = timespan; + return this.apiService.getAccelerations$(); + }) + ), + this.radioGroupForm.get('dateSpan').valueChanges.pipe( + startWith(this.radioGroupForm.controls.dateSpan.value), + switchMap((timespan) => { + return this.apiService.getHistoricalBlockFees$(timespan); + }) + ) + ]).pipe( + tap(([accelerations, blockFeesResponse]) => { + this.prepareChartOptions(accelerations, blockFeesResponse.body); + this.isLoading = false; + this.cd.markForCheck(); + }), + map(([accelerations, blockFeesResponse]) => { + return { + + }; + }), + ); + } + } + + prepareChartOptions(accelerations, blockFees) { + let title: object; + + const blockAccelerations = {}; + + for (const acceleration of accelerations) { + if (acceleration.mined) { + if (!blockAccelerations[acceleration.block_height]) { + blockAccelerations[acceleration.block_height] = []; + } + blockAccelerations[acceleration.block_height].push(acceleration); + } + } + + let last = null; + const data = []; + for (const val of blockFees) { + if (last == null) { + last = val.avgHeight; + } + let totalFeeDelta = 0; + let totalCount = 0; + while (last <= val.avgHeight) { + totalFeeDelta += (blockAccelerations[last] || []).reduce((total, acc) => total + acc.feeDelta, 0); + totalCount += (blockAccelerations[last] || []).length; + last++; + } + data.push({ + ...val, + feeDelta: totalFeeDelta, + accelerations: totalCount, + }); + } + + this.chartOptions = { + title: title, + color: [ + 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' } + ]), + '#1E88E5', + ], + animation: false, + grid: { + right: this.right, + left: this.left, + bottom: this.widget ? 30 : 80, + top: this.widget ? 20 : (this.isMobile() ? 10 : 50), + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + align: 'left', + }, + borderColor: '#000', + formatter: function (data) { + if (data.length <= 0) { + return ''; + } + let tooltip = ` + ${formatterXAxis(this.locale, this.timespan, parseInt(data[0].axisValue, 10))}
`; + + for (const tick of data) { + if (tick.seriesIndex === 0) { + tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')} sats
`; + } else if (tick.seriesIndex === 1) { + tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}
`; + } + } + + if (['24h', '3d'].includes(this.timespan)) { + tooltip += `` + $localize`At block: ${data[0].data[2]}` + ``; + } else { + tooltip += `` + $localize`Around block: ${data[0].data[2]}` + ``; + } + + return tooltip; + }.bind(this) + }, + xAxis: data.length === 0 ? undefined : + { + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + axisLabel: { + hideOverlap: true, + } + }, + legend: (this.widget || data.length === 0) ? undefined : { + data: [ + { + name: 'Total fee delta', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Total accelerations', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + ], + }, + yAxis: data.length === 0 ? undefined : [ + { + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${val / 100_000_000} BTC`; + } + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: '#ffffff66', + opacity: 0.25, + } + }, + }, + { + type: 'value', + position: 'right', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: function(val) { + return `${val}`; + }.bind(this) + }, + splitLine: { + show: false, + }, + }, + ], + series: data.length === 0 ? undefined : [ + { + legendHoverLink: false, + zlevel: 1, + yAxisIndex: 0, + name: 'Total fee delta', + data: data.map(block => [block.timestamp * 1000, block.feeDelta, block.avgHeight]), + type: 'line', + smooth: 0.25, + symbol: 'none', + lineStyle: { + width: 1, + opacity: 1, + } + }, + { + legendHoverLink: false, + zlevel: 0, + yAxisIndex: 1, + name: 'Total accelerations', + data: data.map(block => [block.timestamp * 1000, block.accelerations, block.avgHeight]), + type: 'bar', + barWidth: '100%', + large: true, + }, + ], + dataZoom: (this.widget || data.length === 0 )? undefined : [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 5, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: 20, + right: 15, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + areaStyle: { + opacity: 0, + } + }, + }], + }; + } + + onChartInit(ec) { + this.chartInstance = ec; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + + onSaveChart() { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + const now = new Date(); + // @ts-ignore + this.chartOptions.grid.bottom = 40; + this.chartOptions.backgroundColor = '#11131f'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + excludeComponents: ['dataZoom'], + }), `acceleration-fees-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } +}