diff --git a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.html b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.html new file mode 100644 index 000000000..9ae0ddade --- /dev/null +++ b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.html @@ -0,0 +1,42 @@ + + +
+
+
+ Acceleration Fees + +
+ +
+
+ + + + +
+
+
+ +
+
+
Out-of-band Fees Per Block
+
+
+ +
+
+
+
+
+
diff --git a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss new file mode 100644 index 000000000..c4b4335ee --- /dev/null +++ b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss @@ -0,0 +1,74 @@ +.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: 290px; +} + +h5 { + margin-bottom: 10px; +} + +.card-title { + font-size: 1rem; + color: #4a68b9; +} + +.disabled { + pointer-events: none; + opacity: 0.5; +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts new file mode 100644 index 000000000..d27b10690 --- /dev/null +++ b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts @@ -0,0 +1,390 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnDestroy, OnInit } from '@angular/core'; +import { EChartsOption, graphic } from 'echarts'; +import { Observable, Subscription, combineLatest } from 'rxjs'; +import { map, max, 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, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../../shared/graphs.utils'; +import { StorageService } from '../../../services/storage.service'; +import { MiningService } from '../../../services/mining.service'; +import { ActivatedRoute } from '@angular/router'; +import { Acceleration } from '../../../interfaces/node-api.interface'; + +@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, OnDestroy { + @Input() widget: boolean = false; + @Input() right: number | string = 45; + @Input() left: number | string = 75; + @Input() accelerations$: Observable; + + miningWindowPreference: string; + radioGroupForm: UntypedFormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + hrStatsObservable$: Observable; + statsObservable$: Observable; + statsSubscription: Subscription; + 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`); + this.isLoading = true; + if (this.widget) { + this.miningWindowPreference = '1m'; + this.timespan = this.miningWindowPreference; + + this.statsObservable$ = combineLatest([ + (this.accelerations$ || this.apiService.getAccelerationHistory$({ timeframe: this.miningWindowPreference })), + this.apiService.getHistoricalBlockFees$(this.miningWindowPreference), + ]).pipe( + tap(([accelerations, blockFeesResponse]) => { + this.prepareChartOptions(accelerations, blockFeesResponse.body); + }), + map(([accelerations, blockFeesResponse]) => { + return { + avgFeesPaid: accelerations.filter(acc => acc.status === 'completed').reduce((total, acc) => total + acc.feePaid, 0) / accelerations.length + }; + }), + ); + } 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.getAccelerationHistory$({}); + }) + ), + 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.statsSubscription = this.statsObservable$.subscribe(() => { + this.isLoading = false; + this.cd.markForCheck(); + }); + } + + prepareChartOptions(accelerations, blockFees) { + let title: object; + + const blockAccelerations = {}; + + for (const acceleration of accelerations) { + if (acceleration.status === 'completed') { + if (!blockAccelerations[acceleration.blockHeight]) { + blockAccelerations[acceleration.blockHeight] = []; + } + blockAccelerations[acceleration.blockHeight].push(acceleration); + } + } + + let last = null; + let minValue = Infinity; + let maxValue = 0; + const data = []; + for (const val of blockFees) { + if (last == null) { + last = val.avgHeight; + } + let totalFeeDelta = 0; + let totalFeePaid = 0; + let totalCount = 0; + let blockCount = 0; + while (last <= val.avgHeight) { + blockCount++; + totalFeeDelta += (blockAccelerations[last] || []).reduce((total, acc) => total + acc.feeDelta, 0); + totalFeePaid += (blockAccelerations[last] || []).reduce((total, acc) => total + acc.feePaid, 0); + totalCount += (blockAccelerations[last] || []).length; + last++; + } + minValue = Math.min(minValue, val.avgFees); + maxValue = Math.max(maxValue, val.avgFees); + data.push({ + ...val, + feeDelta: totalFeeDelta, + avgFeePaid: (totalFeePaid / blockCount), + accelerations: totalCount / blockCount, + }); + } + + this.chartOptions = { + title: title, + color: [ + '#8F5FF6', + '#6b6b6b', + ], + 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.reverse()) { + if (tick.data[1] >= 1_000_000) { + tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1] / 100_000_000, this.locale, '1.0-3')} BTC
`; + } else { + tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')} sats
`; + } + } + + 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 : + { + name: this.widget ? undefined : formatterXAxisLabel(this.locale, this.timespan), + nameLocation: 'middle', + nameTextStyle: { + padding: [10, 0, 0, 0], + }, + type: 'category', + boundaryGap: false, + axisLine: { onZero: true }, + axisLabel: { + formatter: val => formatterXAxisTimeCategory(this.locale, this.timespan, parseInt(val, 10)), + align: 'center', + fontSize: 11, + lineHeight: 12, + hideOverlap: true, + padding: [0, 5], + }, + }, + legend: { + data: [ + { + name: 'In-band fees per block', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Out-of-band fees per block', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + ], + selected: { + 'In-band fees per block': false, + 'Out-of-band fees per block': true, + }, + show: !this.widget, + }, + yAxis: data.length === 0 ? undefined : [ + { + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + if (val >= 100_000) { + return `${(val / 100_000_000).toFixed(3)} BTC`; + } else { + return `${val} sats`; + } + } + }, + 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, + name: 'Out-of-band fees per block', + data: data.map(block => [block.timestamp * 1000, block.avgFeePaid, block.avgHeight]), + stack: 'Total', + type: 'bar', + barWidth: '100%', + large: true, + }, + { + legendHoverLink: false, + zlevel: 0, + name: 'In-band fees per block', + data: data.map(block => [block.timestamp * 1000, block.avgFees, block.avgHeight]), + stack: 'Total', + 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, + } + }, + }], + visualMap: { + type: 'continuous', + min: minValue, + max: maxValue, + dimension: 1, + seriesIndex: 1, + show: false, + inRange: { + color: ['#F4511E7f', '#FB8C007f', '#FFB3007f', '#FDD8357f', '#7CB3427f'].reverse() // Gradient color range + } + }, + }; + } + + 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); + } + + ngOnDestroy(): void { + if (this.statsSubscription) { + this.statsSubscription.unsubscribe(); + } + } +} diff --git a/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.html b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.html new file mode 100644 index 000000000..21cd57ae0 --- /dev/null +++ b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.html @@ -0,0 +1,53 @@ +
+
+
+
Transactions
+
+
{{ stats.count }}
+
accelerated
+
+
+
+
Out-of-band Fees
+
+
{{ stats.totalFeesPaid / 100_000_000 | amountShortener: 4 }} BTC
+ + + +
+
+
+
Success rate
+
+
{{ stats.successRate.toFixed(2) }} %
+
mined
+
+
+
+
+ + +
+
+
Transactions
+
+
+
+
+
+
+
Out-of-band Fees
+
+
+
+
+
+
+
Success rate
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.scss b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.scss new file mode 100644 index 000000000..fcc5564a8 --- /dev/null +++ b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.scss @@ -0,0 +1,88 @@ +.card-title { + color: #4a68b9; + font-size: 10px; + margin-bottom: 4px; + font-size: 1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-text { + font-size: 22px; + span { + font-size: 11px; + position: relative; + top: -2px; + display: inline-flex; + } + .green-color { + display: block; + } +} + +.stats-container { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { + flex-direction: row; + } + .item { + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; + } + &:first-child{ + display: none; + @media (min-width: 485px) { + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + &:last-child { + margin-bottom: 0; + } + .card-text span { + color: #ffffff66; + font-size: 12px; + top: 0px; + } + .fee-text{ + border-bottom: 1px solid #ffffff1c; + width: fit-content; + margin: auto; + line-height: 1.45; + padding: 0px 2px; + } + .fiat { + display: block; + font-size: 14px !important; + } + } +} + +.loading-container{ + min-height: 76px; +} + +.card-text { + .skeleton-loader { + width: 100%; + display: block; + &:first-child { + max-width: 90px; + margin: 15px auto 3px; + } + &:last-child { + margin: 10px auto 3px; + max-width: 55px; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.ts b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.ts new file mode 100644 index 000000000..d83303619 --- /dev/null +++ b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { ApiService } from '../../../services/api.service'; +import { StateService } from '../../../services/state.service'; +import { Acceleration } from '../../../interfaces/node-api.interface'; + +@Component({ + selector: 'app-acceleration-stats', + templateUrl: './acceleration-stats.component.html', + styleUrls: ['./acceleration-stats.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccelerationStatsComponent implements OnInit { + @Input() timespan: '24h' | '1w' | '1m' = '24h'; + @Input() accelerations$: Observable; + public accelerationStats$: Observable; + + constructor( + private apiService: ApiService, + private stateService: StateService, + ) { } + + ngOnInit(): void { + this.accelerationStats$ = this.accelerations$.pipe( + switchMap(accelerations => { + let totalFeesPaid = 0; + let totalSucceeded = 0; + let totalCanceled = 0; + for (const acceleration of accelerations) { + if (acceleration.status === 'completed') { + totalSucceeded++; + totalFeesPaid += acceleration.feePaid || 0; + } else if (acceleration.status === 'failed') { + totalCanceled++; + } + } + return of({ + count: totalSucceeded, + totalFeesPaid, + successRate: (totalSucceeded + totalCanceled > 0) ? ((totalSucceeded / (totalSucceeded + totalCanceled)) * 100) : 0.0, + }); + }) + ); + } +} diff --git a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.html b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.html new file mode 100644 index 000000000..32012d363 --- /dev/null +++ b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.html @@ -0,0 +1,91 @@ +
+

Accelerations

+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TXIDFee RateAcceleration BidRequestedOut-of-band FeeBlockStatus
+ + + + + + + {{ (acceleration.feeDelta) | number }} sat + + + + {{ (acceleration.feePaid) | number }} sat + + ~ + + {{ acceleration.blockHeight }} + + Pending + Mined + Canceled +
+ + + + + + + +
+ + +
+
+
+
+ + +
+ There are no active accelerations + There are no recent accelerations +
+
+ +
diff --git a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.scss b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.scss new file mode 100644 index 000000000..69aae18cc --- /dev/null +++ b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.scss @@ -0,0 +1,125 @@ +.spinner-border { + height: 25px; + width: 25px; + margin-top: 13px; +} + +.container-xl { + max-width: 1400px; +} +.container-xl.widget { + padding-left: 0px; + padding-bottom: 0px; +} +.container-xl.legacy { + max-width: 1140px; +} + +.container { + max-width: 100%; +} + +tr, td, th { + border: 0px; + padding-top: 0.65rem !important; + padding-bottom: 0.8rem !important; + + .difference { + margin-left: 0.5em; + + &.positive { + color: rgb(66, 183, 71); + } + &.negative { + color: rgb(183, 66, 66); + } + } +} + +.clear-link { + color: white; +} + +.disabled { + pointer-events: none; + opacity: 0.5; +} + +.progress { + background-color: #2d3348; +} + +.txid { + width: 25%; + @media (max-width: 1100px) { + padding-right: 10px; + } + @media (max-width: 875px) { + display: none; + } + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 30%; +} + +.fee { + width: 35%; +} + +.block { + width: 20%; +} + +.bid { + width: 30%; +} + +.time { + width: 25%; +} + +.status { + width: 20% +} + +/* Tooltip text */ +.tooltip-custom { + position: relative; +} + +.tooltip-custom .tooltiptext { + visibility: hidden; + color: #fff; + text-align: center; + padding: 5px 0; + border-radius: 6px; + position: absolute; + z-index: 1; + top: -40px; + left: 0; +} + +/* Show the tooltip text when you mouse over the tooltip container */ +.tooltip-custom:hover .tooltiptext { + visibility: visible; +} + +.scriptmessage { + overflow: hidden; + display: inline-block; + text-overflow: ellipsis; + vertical-align: middle; + max-width: 50vw; + text-align: left; +} + +.no-data { + color: rgba(255, 255, 255, 0.4); + display: flex; + height: 280px; + width: 100%; + flex-direction: row; + align-items: center; + justify-content: center; +} diff --git a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts new file mode 100644 index 000000000..ddd89d31c --- /dev/null +++ b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts @@ -0,0 +1,71 @@ +import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core'; +import { Observable, catchError, of, switchMap, tap } from 'rxjs'; +import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; +import { ApiService } from '../../../services/api.service'; +import { StateService } from '../../../services/state.service'; +import { WebsocketService } from '../../../services/websocket.service'; + +@Component({ + selector: 'app-accelerations-list', + templateUrl: './accelerations-list.component.html', + styleUrls: ['./accelerations-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccelerationsListComponent implements OnInit { + @Input() widget: boolean = false; + @Input() pending: boolean = false; + @Input() accelerations$: Observable; + + accelerationList$: Observable = undefined; + + isLoading = true; + paginationMaxSize: number; + page = 1; + lastPage = 1; + maxSize = window.innerWidth <= 767.98 ? 3 : 5; + skeletonLines: number[] = []; + + constructor( + private apiService: ApiService, + private websocketService: WebsocketService, + public stateService: StateService, + private cd: ChangeDetectorRef, + ) { + } + + ngOnInit(): void { + if (!this.widget) { + this.websocketService.want(['blocks']); + } + + this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()]; + this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; + + const accelerationObservable$ = this.accelerations$ || (this.pending ? this.apiService.getAccelerations$() : this.apiService.getAccelerationHistory$({ timeframe: '1m' })); + this.accelerationList$ = accelerationObservable$.pipe( + switchMap(accelerations => { + if (this.pending) { + for (const acceleration of accelerations) { + acceleration.status = acceleration.status || 'accelerating'; + } + } + if (this.widget) { + return of(accelerations.slice(-6).reverse()); + } else { + return of(accelerations.reverse()); + } + }), + catchError((err) => { + this.isLoading = false; + return of([]); + }), + tap(() => { + this.isLoading = false; + }) + ); + } + + trackByBlock(index: number, block: BlockExtended): number { + return block.height; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.html b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.html new file mode 100644 index 000000000..91b721db6 --- /dev/null +++ b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.html @@ -0,0 +1,93 @@ + + +
+ +
+ + +
+
+ Active accelerations +
+
+
+
+ +
+
+
+
+ + +
+
+ Acceleration stats  + (1 month) +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+
+ + +
+
+
+ + + + + +
+
+
+ + +
+
+
+ + + +
+
diff --git a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss new file mode 100644 index 000000000..0d1c3b1c0 --- /dev/null +++ b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss @@ -0,0 +1,148 @@ +.dashboard-container { + text-align: center; + margin-top: 0.5rem; + .col { + margin-bottom: 1.5rem; + } +} + +.card { + background-color: #1d1f31; +} + +.graph-card { + height: 100%; + @media (min-width: 992px) { + height: 385px; + } +} + +.card-title { + font-size: 1rem; + color: #4a68b9; +} +.card-title > a { + color: #4a68b9; +} + +.card-body.pool-ranking { + padding: 1.25rem 0.25rem 0.75rem 0.25rem; +} +.card-text { + font-size: 22px; +} + +#blockchain-container { + position: relative; + overflow-x: scroll; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; +} + +#blockchain-container::-webkit-scrollbar { + display: none; +} + +.fade-border { + -webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 80%, transparent 100%) +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.more-padding { + padding: 24px 20px !important; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 22px 20px; + } +} + +.skeleton-loader { + width: 100%; + display: block; + &:first-child { + max-width: 90px; + margin: 15px auto 3px; + } + &:last-child { + margin: 10px auto 3px; + max-width: 55px; + } +} + +.card-text { + font-size: 22px; +} + +.title-link, .title-link:hover, .title-link:focus, .title-link:active { + display: block; + margin-bottom: 10px; + text-decoration: none; + color: inherit; +} + +.lastest-blocks-table { + width: 100%; + text-align: left; + tr, td, th { + border: 0px; + padding-top: 0.65rem !important; + padding-bottom: 0.8rem !important; + } + .table-cell-height { + width: 25%; + } + .table-cell-fee { + width: 25%; + text-align: right; + } + .table-cell-pool { + text-align: left; + width: 30%; + + @media (max-width: 875px) { + display: none; + } + + .pool-name { + margin-left: 1em; + } + } + .table-cell-acceleration-count { + text-align: right; + width: 20%; + } +} + +.card { + height: 385px; +} +.list-card { + height: 410px; +} + +.mempool-block-wrapper { + max-height: 380px; + max-width: 380px; + margin: auto; +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts new file mode 100644 index 000000000..79a77a600 --- /dev/null +++ b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts @@ -0,0 +1,122 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { SeoService } from '../../../services/seo.service'; +import { WebsocketService } from '../../../services/websocket.service'; +import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; +import { StateService } from '../../../services/state.service'; +import { Observable, Subject, catchError, combineLatest, distinctUntilChanged, interval, map, of, share, startWith, switchMap, tap } from 'rxjs'; +import { ApiService } from '../../../services/api.service'; +import { Color } from '../../block-overview-graph/sprite-types'; +import { hexToColor } from '../../block-overview-graph/utils'; +import TxView from '../../block-overview-graph/tx-view'; +import { feeLevels, mempoolFeeColors } from '../../../app.constants'; + +const acceleratedColor: Color = hexToColor('8F5FF6'); +const normalColors = mempoolFeeColors.map(hex => hexToColor(hex + '5F')); + +interface AccelerationBlock extends BlockExtended { + accelerationCount: number, +} + +@Component({ + selector: 'app-accelerator-dashboard', + templateUrl: './accelerator-dashboard.component.html', + styleUrls: ['./accelerator-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AcceleratorDashboardComponent implements OnInit { + blocks$: Observable; + accelerations$: Observable; + pendingAccelerations$: Observable; + minedAccelerations$: Observable; + loadingBlocks: boolean = true; + + constructor( + private seoService: SeoService, + private websocketService: WebsocketService, + private apiService: ApiService, + private stateService: StateService, + ) { + this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Accelerator Dashboard`); + } + + ngOnInit(): void { + this.websocketService.want(['blocks', 'mempool-blocks', 'stats']); + + this.pendingAccelerations$ = interval(30000).pipe( + startWith(true), + switchMap(() => { + return this.apiService.getAccelerations$(); + }), + catchError((e) => { + return of([]); + }), + share(), + ); + + this.accelerations$ = this.stateService.chainTip$.pipe( + distinctUntilChanged(), + switchMap((chainTip) => { + return this.apiService.getAccelerationHistory$({ timeframe: '1m' }); + }), + catchError((e) => { + return of([]); + }), + share(), + ); + + this.minedAccelerations$ = this.accelerations$.pipe( + map(accelerations => { + return accelerations.filter(acc => ['mined', 'completed'].includes(acc.status)) + }) + ); + + this.blocks$ = combineLatest([ + this.accelerations$, + this.stateService.blocks$.pipe( + switchMap((blocks) => { + if (this.stateService.env.MINING_DASHBOARD === true) { + for (const block of blocks) { + // @ts-ignore: Need to add an extra field for the template + block.extras.pool.logo = `/resources/mining-pools/` + + block.extras.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; + } + } + return of(blocks as AccelerationBlock[]); + }), + tap(() => { + this.loadingBlocks = false; + }) + ) + ]).pipe( + switchMap(([accelerations, blocks]) => { + const blockMap = {}; + for (const block of blocks) { + blockMap[block.id] = block; + } + const accelerationsByBlock: { [ hash: string ]: Acceleration[] } = {}; + for (const acceleration of accelerations) { + if (['mined', 'completed'].includes(acceleration.status) && acceleration.pools.includes(blockMap[acceleration.blockHash]?.extras.pool.id)) { + if (!accelerationsByBlock[acceleration.blockHash]) { + accelerationsByBlock[acceleration.blockHash] = []; + } + accelerationsByBlock[acceleration.blockHash].push(acceleration); + } + } + return of(blocks.slice(0, 6).map(block => { + block.accelerationCount = (accelerationsByBlock[block.id] || []).length; + return block; + })); + }) + ); + } + + getAcceleratorColor(tx: TxView): Color { + if (tx.status === 'accelerated' || tx.acc) { + return acceleratedColor; + } else { + const rate = tx.fee / tx.vsize; // color by simple single-tx fee rate + const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1; + return normalColors[feeLevelIndex] || normalColors[mempoolFeeColors.length - 1]; + } + } +} diff --git a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.html b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.html new file mode 100644 index 000000000..c94bbf43a --- /dev/null +++ b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.html @@ -0,0 +1,53 @@ +
+
+
+
Transactions
+
+
{{ stats.count }}
+
accelerated
+
+
+
+
Avg Max Bid
+
+
{{ stats.avgFeeDelta / 100_000_000 | amountShortener: 4 }} BTC
+ + + +
+
+
+
Total vsize
+
+
+
{{ (stats.totalVsize / 1_000_000 * 100).toFixed(2) }}% of next block
+
+
+
+
+ + +
+
+
Transactions
+
+
+
+
+
+
+
Avg Max Bid
+
+
+
+
+
+
+
Total vsize
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.scss b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.scss new file mode 100644 index 000000000..fcc5564a8 --- /dev/null +++ b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.scss @@ -0,0 +1,88 @@ +.card-title { + color: #4a68b9; + font-size: 10px; + margin-bottom: 4px; + font-size: 1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-text { + font-size: 22px; + span { + font-size: 11px; + position: relative; + top: -2px; + display: inline-flex; + } + .green-color { + display: block; + } +} + +.stats-container { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { + flex-direction: row; + } + .item { + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; + } + &:first-child{ + display: none; + @media (min-width: 485px) { + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + &:last-child { + margin-bottom: 0; + } + .card-text span { + color: #ffffff66; + font-size: 12px; + top: 0px; + } + .fee-text{ + border-bottom: 1px solid #ffffff1c; + width: fit-content; + margin: auto; + line-height: 1.45; + padding: 0px 2px; + } + .fiat { + display: block; + font-size: 14px !important; + } + } +} + +.loading-container{ + min-height: 76px; +} + +.card-text { + .skeleton-loader { + width: 100%; + display: block; + &:first-child { + max-width: 90px; + margin: 15px auto 3px; + } + &:last-child { + margin: 10px auto 3px; + max-width: 55px; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts new file mode 100644 index 000000000..f344c37a0 --- /dev/null +++ b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { ApiService } from '../../../services/api.service'; +import { Acceleration } from '../../../interfaces/node-api.interface'; + +@Component({ + selector: 'app-pending-stats', + templateUrl: './pending-stats.component.html', + styleUrls: ['./pending-stats.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PendingStatsComponent implements OnInit { + @Input() accelerations$: Observable; + public accelerationStats$: Observable; + + constructor( + private apiService: ApiService, + ) { } + + ngOnInit(): void { + this.accelerationStats$ = (this.accelerations$ || this.apiService.getAccelerations$()).pipe( + switchMap(accelerations => { + let totalAccelerations = 0; + let totalFeeDelta = 0; + let totalVsize = 0; + for (const acceleration of accelerations) { + totalAccelerations++; + totalFeeDelta += acceleration.feeDelta || 0; + totalVsize += acceleration.effectiveVsize || 0; + } + return of({ + count: totalAccelerations, + totalFeeDelta, + avgFeeDelta: totalAccelerations ? totalFeeDelta / totalAccelerations : 0, + totalVsize, + }); + }) + ); + } +} diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html index 77c35cea8..8676b8a44 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html @@ -1,13 +1,13 @@ - + -
-
+
+
Block Fee Rates -
+
@@ -45,11 +45,45 @@
-
+
+
+
Avg Block Fee (24h)
+

+ +

+
+
+
Avg Block Fee (1m)
+

+ +

+
+
+
+ +
-
\ No newline at end of file +
+ + +
+
Avg Block Fee (24h)
+

+ +

+
+
+ +
+
Avg Block Fee (1m)
+

+ +

+
+
\ No newline at end of file diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss index f4f4dcc77..21dd458b5 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss @@ -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; diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts index c4d061927..0283b2d00 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts @@ -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, 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; @@ -40,6 +41,7 @@ export class BlockFeeRatesGraphComponent implements OnInit { renderer: 'svg', }; + hrStatsObservable$: Observable; statsObservable$: Observable; isLoading = true; formatNumber = formatNumber; @@ -57,39 +59,61 @@ 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.hrStatsObservable$ = combineLatest([ + this.apiService.getHistoricalBlockFeeRates$('24h'), + this.stateService.rateUnits$ + ]).pipe( + map(([response, rateUnits]) => { + 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, + }; + }), + share(), + ); 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 +124,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 +163,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, seriesData['Median'][i][2]]); + } + series.push({ + zlevel: 1, + name: 'Moving average', + 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,13 +209,19 @@ 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(), @@ -184,9 +245,9 @@ export class BlockFeeRatesGraphComponent implements OnInit { for (const rate of data.reverse()) { if (weightMode) { - tooltip += `${rate.marker} ${rate.seriesName}: ${rate.data[1] / 4} sats/WU
`; + tooltip += `${rate.marker} ${rate.seriesName}: ${(rate.data[1] / 4).toFixed(2)} sats/WU
`; } else { - tooltip += `${rate.marker} ${rate.seriesName}: ${rate.data[1]} sats/vByte
`; + tooltip += `${rate.marker} ${rate.seriesName}: ${rate.data[1].toFixed(2)} sats/vByte
`; } } @@ -201,7 +262,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 +279,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 +317,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, diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index 68d2a1bf3..1fc173a2d 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -4,7 +4,7 @@ import { FastVertexArray } from './fast-vertex-array'; import BlockScene from './block-scene'; import TxSprite from './tx-sprite'; import TxView from './tx-view'; -import { Position } from './sprite-types'; +import { Color, Position } from './sprite-types'; import { Price } from '../../services/price.service'; import { StateService } from '../../services/state.service'; import { Subscription } from 'rxjs'; @@ -27,6 +27,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @Input() unavailable: boolean = false; @Input() auditHighlighting: boolean = false; @Input() blockConversion: Price; + @Input() overrideColors: ((tx: TxView) => Color) | null = null; @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>(); @Output() txHoverEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter(); @@ -91,6 +92,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (changes.auditHighlighting) { this.setHighlightingEnabled(this.auditHighlighting); } + if (changes.overrideColor) { + this.scene.setColorFunction(this.overrideColors); + } } ngOnDestroy(): void { @@ -228,7 +232,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } else { this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, - highlighting: this.auditHighlighting, animationDuration: this.animationDuration, animationOffset: this.animationOffset }); + highlighting: this.auditHighlighting, animationDuration: this.animationDuration, animationOffset: this.animationOffset, + colorFunction: this.overrideColors }); this.start(); } } diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index 2569a3bb2..77b7c2e05 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -1,12 +1,26 @@ import { FastVertexArray } from './fast-vertex-array'; import TxView from './tx-view'; import { TransactionStripped } from '../../interfaces/websocket.interface'; -import { Position, Square, ViewUpdateParams } from './sprite-types'; +import { Color, Position, Square, ViewUpdateParams } from './sprite-types'; +import { feeLevels, mempoolFeeColors } from '../../app.constants'; +import { darken, desaturate, hexToColor } from './utils'; + +const feeColors = mempoolFeeColors.map(hexToColor); +const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); +const marginalFeeColors = feeColors.map((color) => darken(desaturate(color, 0.8), 1.1)); +const auditColors = { + censored: hexToColor('f344df'), + missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), + added: hexToColor('0099ff'), + selected: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7), + accelerated: hexToColor('8F5FF6'), +}; export default class BlockScene { scene: { count: number, offset: { x: number, y: number}}; vertexArray: FastVertexArray; txs: { [key: string]: TxView }; + getColor: ((tx: TxView) => Color) = defaultColorFunction; orientation: string; flip: boolean; animationDuration: number = 1000; @@ -26,11 +40,11 @@ export default class BlockScene { animateUntil = 0; dirty: boolean; - constructor({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting }: + constructor({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting, colorFunction }: { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } + orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } ) { - this.init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting }); + this.init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting, colorFunction }); } resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void { @@ -63,6 +77,14 @@ export default class BlockScene { } } + setColorFunction(colorFunction: ((tx: TxView) => Color) | null): void { + this.getColor = colorFunction; + this.dirty = true; + if (this.initialised && this.scene) { + this.updateColors(performance.now(), 50); + } + } + // Destroy the current layout and clean up graphics sprites without any exit animation destroy(): void { Object.values(this.txs).forEach(tx => tx.destroy()); @@ -86,7 +108,7 @@ export default class BlockScene { this.applyTxUpdate(txView, { display: { position: txView.screenPosition, - color: txView.getColor() + color: this.getColor(txView) }, duration: 0 }); @@ -217,9 +239,9 @@ export default class BlockScene { this.animateUntil = Math.max(this.animateUntil, tx.setHighlight(value)); } - private init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting }: + private init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting, colorFunction }: { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } + orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } ): void { this.animationDuration = animationDuration || 1000; this.configAnimationOffset = animationOffset; @@ -228,6 +250,7 @@ export default class BlockScene { this.flip = flip; this.vertexArray = vertexArray; this.highlightingEnabled = highlighting; + this.getColor = colorFunction || defaultColorFunction; this.scene = { count: 0, @@ -261,9 +284,23 @@ export default class BlockScene { } } + private updateColor(tx: TxView, startTime: number, delay: number, animate: boolean = true, duration: number = 500): void { + if (tx.dirty || this.dirty) { + const txColor = this.getColor(tx); + this.applyTxUpdate(tx, { + display: { + color: txColor, + }, + start: startTime, + delay, + duration: animate ? duration : 0, + }); + } + } + private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left', animate: boolean = true): void { if (!tx.initialised) { - const txColor = tx.getColor(); + const txColor = this.getColor(tx); this.applyTxUpdate(tx, { display: { position: { @@ -321,6 +358,15 @@ export default class BlockScene { this.dirty = false; } + private updateColors(startTime: number, delay: number = 50, animate: boolean = true, duration: number = 500): void { + const ids = this.getTxList(); + startTime = startTime || performance.now(); + for (const id of ids) { + this.updateColor(this.txs[id], startTime, delay, animate, duration); + } + this.dirty = false; + } + private remove(id: string, startTime: number, direction: string = 'left'): TxView | void { const tx = this.txs[id]; if (tx) { @@ -858,3 +904,48 @@ class BlockLayout { function feeRateDescending(a: TxView, b: TxView) { return b.feerate - a.feerate; } + +function defaultColorFunction(tx: TxView): Color { + const rate = tx.fee / tx.vsize; // color by simple single-tx fee rate + const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1; + const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1]; + // Normal mode + if (!tx.scene?.highlightingEnabled) { + if (tx.acc) { + return auditColors.accelerated; + } else { + return feeLevelColor; + } + return feeLevelColor; + } + // Block audit + switch(tx.status) { + case 'censored': + return auditColors.censored; + case 'missing': + case 'sigop': + case 'rbf': + return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; + case 'fresh': + case 'freshcpfp': + return auditColors.missing; + case 'added': + return auditColors.added; + case 'selected': + return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; + case 'accelerated': + return auditColors.accelerated; + case 'found': + if (tx.context === 'projected') { + return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; + } else { + return feeLevelColor; + } + default: + if (tx.acc) { + return auditColors.accelerated; + } else { + return feeLevelColor; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index db2c4f6ae..4e2d855e6 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -2,24 +2,13 @@ import TxSprite from './tx-sprite'; import { FastVertexArray } from './fast-vertex-array'; import { TransactionStripped } from '../../interfaces/websocket.interface'; import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types'; -import { feeLevels, mempoolFeeColors } from '../../app.constants'; +import { hexToColor } from './utils'; import BlockScene from './block-scene'; const hoverTransitionTime = 300; const defaultHoverColor = hexToColor('1bd8f4'); const defaultHighlightColor = hexToColor('800080'); -const feeColors = mempoolFeeColors.map(hexToColor); -const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); -const marginalFeeColors = feeColors.map((color) => darken(desaturate(color, 0.8), 1.1)); -const auditColors = { - censored: hexToColor('f344df'), - missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), - added: hexToColor('0099ff'), - selected: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7), - accelerated: hexToColor('8F5FF6'), -}; - // convert from this class's update format to TxSprite's update format function toSpriteUpdate(params: ViewUpdateParams): SpriteUpdateParams { return { @@ -195,77 +184,4 @@ export default class TxView implements TransactionStripped { this.dirty = false; return performance.now() + hoverTransitionTime; } - - getColor(): Color { - const rate = this.fee / this.vsize; // color by simple single-tx fee rate - const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1; - const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1]; - // Normal mode - if (!this.scene?.highlightingEnabled) { - if (this.acc) { - return auditColors.accelerated; - } else { - return feeLevelColor; - } - return feeLevelColor; - } - // Block audit - switch(this.status) { - case 'censored': - return auditColors.censored; - case 'missing': - case 'sigop': - case 'rbf': - return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; - case 'fresh': - case 'freshcpfp': - return auditColors.missing; - case 'added': - return auditColors.added; - case 'selected': - return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; - case 'accelerated': - return auditColors.accelerated; - case 'found': - if (this.context === 'projected') { - return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; - } else { - return feeLevelColor; - } - default: - if (this.acc) { - return auditColors.accelerated; - } else { - return feeLevelColor; - } - } - } -} - -function hexToColor(hex: string): Color { - return { - r: parseInt(hex.slice(0, 2), 16) / 255, - g: parseInt(hex.slice(2, 4), 16) / 255, - b: parseInt(hex.slice(4, 6), 16) / 255, - a: 1 - }; -} - -function desaturate(color: Color, amount: number): Color { - const gray = (color.r + color.g + color.b) / 6; - return { - r: color.r + ((gray - color.r) * amount), - g: color.g + ((gray - color.g) * amount), - b: color.b + ((gray - color.b) * amount), - a: color.a, - }; -} - -function darken(color: Color, amount: number): Color { - return { - r: color.r * amount, - g: color.g * amount, - b: color.b * amount, - a: color.a, - } } diff --git a/frontend/src/app/components/block-overview-graph/utils.ts b/frontend/src/app/components/block-overview-graph/utils.ts new file mode 100644 index 000000000..a0bb8e868 --- /dev/null +++ b/frontend/src/app/components/block-overview-graph/utils.ts @@ -0,0 +1,29 @@ +import { Color } from './sprite-types'; + +export function hexToColor(hex: string): Color { + return { + r: parseInt(hex.slice(0, 2), 16) / 255, + g: parseInt(hex.slice(2, 4), 16) / 255, + b: parseInt(hex.slice(4, 6), 16) / 255, + a: hex.length > 6 ? parseInt(hex.slice(6, 8), 16) / 255 : 1 + }; +} + +export function desaturate(color: Color, amount: number): Color { + const gray = (color.r + color.g + color.b) / 6; + return { + r: color.r + ((gray - color.r) * amount), + g: color.g + ((gray - color.g) * amount), + b: color.b + ((gray - color.b) * amount), + a: color.a, + }; +} + +export function darken(color: Color, amount: number): Color { + return { + r: color.r * amount, + g: color.g * amount, + b: color.b * amount, + a: color.a, + } +} \ No newline at end of file diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts index ba066d10a..0da8ca7b5 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -79,7 +79,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } enabledMiningInfoIfNeeded(url) { - this.showMiningInfo = url.indexOf('/mining') !== -1; + this.showMiningInfo = url.includes('/mining') || url.includes('/acceleration'); this.cd.markForCheck(); // Need to update the view asap } diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index dc1cc2c30..f31e262ce 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -78,7 +78,7 @@
-
+
diff --git a/frontend/src/app/components/master-page/master-page.component.scss b/frontend/src/app/components/master-page/master-page.component.scss index 79f5e8a12..a40b4ec61 100644 --- a/frontend/src/app/components/master-page/master-page.component.scss +++ b/frontend/src/app/components/master-page/master-page.component.scss @@ -241,7 +241,7 @@ main { } // empty sidenav -.sidenav { +.empty-sidenav { z-index: 1; background-color: transparent; width: 0px; diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html index 503f2e38d..1e0cba48c 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html @@ -5,5 +5,6 @@ [blockLimit]="stateService.blockVSize" [orientation]="timeLtr ? 'right' : 'left'" [flip]="true" + [overrideColors]="overrideColors" (txClickEvent)="onTxClick($event)" > diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index 226be5210..09eac989e 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -8,6 +8,8 @@ import { switchMap, filter } from 'rxjs/operators'; import { WebsocketService } from '../../services/websocket.service'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { Router } from '@angular/router'; +import { Color } from '../block-overview-graph/sprite-types'; +import TxView from '../block-overview-graph/tx-view'; @Component({ selector: 'app-mempool-block-overview', @@ -16,6 +18,7 @@ import { Router } from '@angular/router'; }) export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { @Input() index: number; + @Input() overrideColors: ((tx: TxView) => Color) | null = null; @Output() txPreviewEvent = new EventEmitter(); @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index 0ddbbd4b7..61e62f642 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -90,7 +90,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { ) { } enabledMiningInfoIfNeeded(url) { - this.showMiningInfo = url.indexOf('/mining') !== -1; + this.showMiningInfo = url.includes('/mining') || url.includes('/acceleration'); this.cd.markForCheck(); // Need to update the view asap } diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index a2160977c..85905d1f1 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -3,6 +3,7 @@ import { NgxEchartsModule } from 'ngx-echarts'; import { GraphsRoutingModule } from './graphs.routing.module'; import { SharedModule } from '../shared/shared.module'; +import { AccelerationFeesGraphComponent } from '../components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component'; import { BlockFeesGraphComponent } from '../components/block-fees-graph/block-fees-graph.component'; import { BlockRewardsGraphComponent } from '../components/block-rewards-graph/block-rewards-graph.component'; import { BlockFeeRatesGraphComponent } from '../components/block-fee-rates-graph/block-fee-rates-graph.component'; @@ -19,6 +20,7 @@ import { PoolComponent } from '../components/pool/pool.component'; import { TelevisionComponent } from '../components/television/television.component'; import { DashboardComponent } from '../dashboard/dashboard.component'; import { MiningDashboardComponent } from '../components/mining-dashboard/mining-dashboard.component'; +import { AcceleratorDashboardComponent } from '../components/acceleration/accelerator-dashboard/accelerator-dashboard.component'; import { HashrateChartComponent } from '../components/hashrate-chart/hashrate-chart.component'; import { HashrateChartPoolsComponent } from '../components/hashrates-chart-pools/hashrate-chart-pools.component'; import { BlockHealthGraphComponent } from '../components/block-health-graph/block-health-graph.component'; @@ -30,12 +32,14 @@ import { CommonModule } from '@angular/common'; MempoolBlockComponent, MiningDashboardComponent, + AcceleratorDashboardComponent, PoolComponent, PoolRankingComponent, TelevisionComponent, StatisticsComponent, GraphsComponent, + AccelerationFeesGraphComponent, BlockFeesGraphComponent, BlockRewardsGraphComponent, BlockFeeRatesGraphComponent, diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index 346bcf7f1..0f217eb6e 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -10,12 +10,15 @@ import { HashrateChartComponent } from '../components/hashrate-chart/hashrate-ch import { HashrateChartPoolsComponent } from '../components/hashrates-chart-pools/hashrate-chart-pools.component'; import { MempoolBlockComponent } from '../components/mempool-block/mempool-block.component'; import { MiningDashboardComponent } from '../components/mining-dashboard/mining-dashboard.component'; +import { AcceleratorDashboardComponent } from '../components/acceleration/accelerator-dashboard/accelerator-dashboard.component'; import { PoolRankingComponent } from '../components/pool-ranking/pool-ranking.component'; import { PoolComponent } from '../components/pool/pool.component'; import { StartComponent } from '../components/start/start.component'; import { StatisticsComponent } from '../components/statistics/statistics.component'; import { TelevisionComponent } from '../components/television/television.component'; import { DashboardComponent } from '../dashboard/dashboard.component'; +import { AccelerationFeesGraphComponent } from '../components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component'; +import { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; const routes: Routes = [ { @@ -37,6 +40,22 @@ const routes: Routes = [ }, ] }, + { + path: 'acceleration', + data: { networks: ['bitcoin'] }, + component: StartComponent, + children: [ + { + path: '', + component: AcceleratorDashboardComponent, + } + ] + }, + { + path: 'acceleration-list', + data: { networks: ['bitcoin'] }, + component: AccelerationsListComponent, + }, { path: 'mempool-block/:id', data: { networks: ['bitcoin', 'liquid'] }, @@ -93,6 +112,11 @@ const routes: Routes = [ data: { networks: ['bitcoin'] }, component: BlockSizesWeightsGraphComponent, }, + { + path: 'acceleration/fees', + data: { networks: ['bitcoin'] }, + component: AccelerationFeesGraphComponent, + }, { path: 'lightning', data: { preload: true, networks: ['bitcoin'] }, diff --git a/frontend/src/app/shared/components/btc/btc.component.html b/frontend/src/app/shared/components/btc/btc.component.html new file mode 100644 index 000000000..c13a8ff31 --- /dev/null +++ b/frontend/src/app/shared/components/btc/btc.component.html @@ -0,0 +1,8 @@ +{{ valueOverride }} +‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ value | number }} + + L- + tL- + t- + s-{{ unit }} + \ No newline at end of file diff --git a/frontend/src/app/shared/components/btc/btc.component.scss b/frontend/src/app/shared/components/btc/btc.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/shared/components/btc/btc.component.ts b/frontend/src/app/shared/components/btc/btc.component.ts new file mode 100644 index 000000000..4e62b07ff --- /dev/null +++ b/frontend/src/app/shared/components/btc/btc.component.ts @@ -0,0 +1,44 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { StateService } from '../../../services/state.service'; + +@Component({ + selector: 'app-btc', + templateUrl: './btc.component.html', + styleUrls: ['./btc.component.scss'] +}) +export class BtcComponent implements OnInit, OnChanges { + @Input() satoshis: number; + @Input() addPlus = false; + @Input() valueOverride: string | undefined = undefined; + + value: number; + unit: string; + + network = ''; + stateSubscription: Subscription; + + constructor( + private stateService: StateService, + ) { } + + ngOnInit() { + this.stateSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network); + } + + ngOnDestroy() { + if (this.stateSubscription) { + this.stateSubscription.unsubscribe(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (this.satoshis >= 1_000_000) { + this.value = (this.satoshis / 100_000_000); + this.unit = 'BTC' + } else { + this.value = Math.round(this.satoshis); + this.unit = 'sats' + } + } +} diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html index 676db35e5..fba04e605 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.html +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html @@ -11,7 +11,7 @@ Explore the full Bitcoin ecosystem

-
+
diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.scss b/frontend/src/app/shared/components/global-footer/global-footer.component.scss index 148383cb4..79c7dcfce 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.scss +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.scss @@ -63,7 +63,6 @@ footer .row.main .links .category:not(:first-child) { } footer .site-options { - float: right; margin-top: -20px; } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 82327c561..52123f995 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -73,6 +73,7 @@ import { IndexingProgressComponent } from '../components/indexing-progress/index import { SvgImagesComponent } from '../components/svg-images/svg-images.component'; import { ChangeComponent } from '../components/change/change.component'; import { SatsComponent } from './components/sats/sats.component'; +import { BtcComponent } from './components/btc/btc.component'; import { FeeRateComponent } from './components/fee-rate/fee-rate.component'; import { TruncateComponent } from './components/truncate/truncate.component'; import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component'; @@ -85,6 +86,9 @@ import { GlobalFooterComponent } from './components/global-footer/global-footer. import { AcceleratePreviewComponent } from '../components/accelerate-preview/accelerate-preview.component'; import { AccelerateFeeGraphComponent } from '../components/accelerate-preview/accelerate-fee-graph.component'; import { MempoolErrorComponent } from './components/mempool-error/mempool-error.component'; +import { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; +import { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component'; +import { AccelerationStatsComponent } from '../components/acceleration/acceleration-stats/acceleration-stats.component'; import { BlockViewComponent } from '../components/block-view/block-view.component'; import { EightBlocksComponent } from '../components/eight-blocks/eight-blocks.component'; @@ -167,6 +171,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir SvgImagesComponent, ChangeComponent, SatsComponent, + BtcComponent, FeeRateComponent, TruncateComponent, SearchResultsComponent, @@ -190,6 +195,9 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir OnlyVsizeDirective, OnlyWeightDirective, MempoolErrorComponent, + AccelerationsListComponent, + AccelerationStatsComponent, + PendingStatsComponent, ], imports: [ CommonModule, @@ -287,6 +295,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir SvgImagesComponent, ChangeComponent, SatsComponent, + BtcComponent, FeeRateComponent, TruncateComponent, SearchResultsComponent, @@ -300,6 +309,9 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir AcceleratePreviewComponent, AccelerateFeeGraphComponent, MempoolErrorComponent, + AccelerationsListComponent, + AccelerationStatsComponent, + PendingStatsComponent, MempoolBlockOverviewComponent, ClockchainComponent, diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 0e26c4eb3..be8cec328 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -996,6 +996,27 @@ th { .btn-audit { margin-left: .5em; } + + .sidenav { + @extend .sidenav; + margin-left: 0px !important; + margin-right: -250px; + } + + .sidenav.open { + margin-right: 0px; + } + + .profile_image_container { + @extend .profile_image_container; + margin-right: 0px !important; + margin-left: 15px; + } + + #blockchain-container.with-menu { + width: calc(100% + 120px); + left: 120px; + } } .scriptmessage {