diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index 74e37d298..cf83a7f2c 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -14,6 +14,10 @@ [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="mining.hashrate-difficulty">Hashrate & Difficulty Block Fee Rates + Minimum Daily Fee Rate + Share of days at or below a fee rate Block Fees + +
+
+
+
+ Share of days at or below a fee rate + +
+
Cumulative distribution of the minimum daily fee rate (prioritized transactions excluded)
+
+
+ +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ No minimum daily fee rate data available yet. +
+ +
+
+
+
+
+ +
diff --git a/frontend/src/app/components/min-fee-rate-cdf-graph/min-fee-rate-cdf-graph.component.scss b/frontend/src/app/components/min-fee-rate-cdf-graph/min-fee-rate-cdf-graph.component.scss new file mode 100644 index 000000000..ad23fe65b --- /dev/null +++ b/frontend/src/app/components/min-fee-rate-cdf-graph/min-fee-rate-cdf-graph.component.scss @@ -0,0 +1,108 @@ +.full-container { + display: flex; + flex-direction: column; + padding: 0px 15px; + width: 100%; + height: calc(100vh - 225px); + min-height: 400px; + @media (min-width: 992px) { + height: calc(100vh - 150px); + } +} + +.chart-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + @media (min-width: 768px) { + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + } + + .heading { + min-width: 0; + + .title { + font-size: 18px; + line-height: 1.2; + @media (min-width: 465px) { + font-size: 20px; + } + .btn { + vertical-align: baseline; + } + } + + .subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--transparent-fg); + } + } + +} + +.chart-controls { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + margin: 12px 0 4px; + @media (min-width: 768px) { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + } + + .threshold-input { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + label { + margin: 0; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--transparent-fg); + white-space: nowrap; + } + input { + width: 90px; + } + } + + .formRadioGroup { + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } + } +} + +.chart { + display: flex; + flex: 1; + height: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } +} + +.chart-widget { + width: 100%; + height: 100%; + max-height: 238px; +} + +.no-data { + padding: 30px 0; +} diff --git a/frontend/src/app/components/min-fee-rate-cdf-graph/min-fee-rate-cdf-graph.component.ts b/frontend/src/app/components/min-fee-rate-cdf-graph/min-fee-rate-cdf-graph.component.ts new file mode 100644 index 000000000..c245eb9a7 --- /dev/null +++ b/frontend/src/app/components/min-fee-rate-cdf-graph/min-fee-rate-cdf-graph.component.ts @@ -0,0 +1,320 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { EChartsOption } from '@app/graphs/echarts'; +import { Observable, combineLatest, of } from 'rxjs'; +import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { SeoService } from '@app/services/seo.service'; +import { formatNumber } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { download } from '@app/shared/graphs.utils'; +import { StorageService } from '@app/services/storage.service'; +import { MiningService } from '@app/services/mining.service'; +import { StateService } from '@app/services/state.service'; +import { ActivatedRoute } from '@angular/router'; +import { MinFeeRateDay } from '@app/interfaces/node-api.interface'; +import { DEFAULT_MIN_FEE_RATE_THRESHOLD, MinFeeRateService } from '@app/services/min-fee-rate.service'; +import { chartColors } from '@app/app.constants'; + +const CURVE_COLOR = chartColors[8]; // '#00897B' + +// The series is one point per day, so anything shorter than a month is degenerate. +const TIMESPANS = ['1m', '3m', '6m', '1y', '2y', '3y', 'all']; + +@Component({ + selector: 'app-min-fee-rate-cdf-graph', + templateUrl: './min-fee-rate-cdf-graph.component.html', + styleUrls: ['./min-fee-rate-cdf-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 99; + } + `], + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MinFeeRateCdfGraphComponent implements OnInit { + @Input() widget = false; + + miningWindowPreference: string; + radioGroupForm: UntypedFormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + statsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + + data: MinFeeRateDay[] = []; + threshold = DEFAULT_MIN_FEE_RATE_THRESHOLD; + + // Share of days at or below the threshold: drives the marker and the legend readout. + percentBelow = 0; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private minFeeRateService: MinFeeRateService, + private formBuilder: UntypedFormBuilder, + private storageService: StorageService, + private miningService: MiningService, + public stateService: StateService, + private route: ActivatedRoute, + private cd: ChangeDetectorRef, + ) { + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1m', threshold: DEFAULT_MIN_FEE_RATE_THRESHOLD }); + this.radioGroupForm.controls.dateSpan.setValue('1m'); + } + + ngOnInit(): void { + if (this.widget) { + this.miningWindowPreference = '1m'; + } else { + this.seoService.setTitle($localize`:@@mining.min-fee-rate-cdf:Share of days at or below a fee rate`); + this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.min-fee-rate-cdf:The cumulative share of days whose minimum fee-merit fee rate was at or below a given fee rate.`); + // miningWindowPreference is shared across every mining graph, so floor whatever it + // holds at the shortest timespan this chart offers. + this.miningWindowPreference = this.miningService.getDefaultTimespan('1m'); + } + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference, threshold: DEFAULT_MIN_FEE_RATE_THRESHOLD }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + if (!this.widget) { + this.route + .fragment + .subscribe((fragment) => { + if (TIMESPANS.indexOf(fragment) > -1) { + this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); + } + }); + } + + // Threshold changes only move the marker and recompute the stats, no refetch. + this.radioGroupForm.get('threshold').valueChanges.subscribe((value) => { + const parsed = parseFloat(value); + this.threshold = isNaN(parsed) || parsed < 0 ? 0 : parsed; + this.updateChart(); + this.cd.markForCheck(); + }); + + this.statsObservable$ = combineLatest([ + this.widget ? of(this.miningWindowPreference) : this.radioGroupForm.get('dateSpan').valueChanges.pipe(startWith(this.radioGroupForm.controls.dateSpan.value)), + ]).pipe( + switchMap(([timespan]) => { + if (!this.widget) { + this.storageService.setValue('miningWindowPreference', timespan); + } + this.timespan = timespan; + this.isLoading = true; + return this.minFeeRateService.getMinFeeRates$(timespan) + .pipe( + tap((response) => { + this.data = response.body || []; + this.updateChart(); + this.isLoading = false; + this.cd.markForCheck(); + }), + map((response) => { + return { + dayCount: parseInt(response.headers.get('x-total-count'), 10), + }; + }), + ); + }), + share(), + ); + } + + updateChart(): void { + this.percentBelow = this.minFeeRateService.getStats(this.data, this.threshold).percentBelow; + this.prepareChartOptions(this.minFeeRateService.buildCdf(this.data)); + } + + formatFeeRate(val: number): string { + return this.minFeeRateService.formatFeeRate(val); + } + + prepareChartOptions(cdf: number[][]): void { + const hasData = cdf.length > 0; + const curveLabel = $localize`:@@mining.min-fee-rate-cdf.legend-curve:cumulative % of days ≤ fee rate`; + const thresholdValue = this.formatFeeRate(this.threshold); + const thresholdPercent = `${formatNumber(this.percentBelow, this.locale, '1.1-1')}%`; + const thresholdLabel = $localize`:@@mining.min-fee-rate-cdf.legend-threshold:threshold ${thresholdValue}:VALUE: sat/vB → ${thresholdPercent}:PERCENT:`; + + this.chartOptions = { + color: [CURVE_COLOR], + animation: false, + grid: { + right: this.widget ? 10 : 30, + left: this.widget ? 45 : 65, + bottom: this.widget ? 30 : 75, + top: 20, + }, + legend: (this.widget || !hasData) ? undefined : { + bottom: 0, + left: 'center', + width: '90%', + data: [curveLabel, thresholdLabel], + textStyle: { + color: 'var(--transparent-fg)', + fontSize: 11, + }, + inactiveColor: 'rgb(110, 112, 121)', + }, + 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: 'var(--tooltip-grey)', + align: 'left', + }, + borderColor: '#000', + formatter: function (data: any): string { + const point = data.find(d => d.seriesName === curveLabel); + if (!point) { + return ''; + } + let tooltip = `≤ ${this.formatFeeRate(+point.data[0])} sat/vB
`; + tooltip += `${point.marker} ` + $localize`Share of days` + `: ${(+point.data[1]).toFixed(1)}%`; + return tooltip; + }.bind(this) + }, + xAxis: !hasData ? undefined : { + name: this.widget ? undefined : $localize`:@@mining.min-fee-rate-cdf.x-axis:fee rate (sat/vB)`, + nameLocation: 'middle', + nameTextStyle: { + color: 'rgb(110, 112, 121)', + fontSize: 12, + padding: [12, 0, 0, 0], + }, + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + fontSize: 11, + formatter: (val): string => this.formatFeeRate(val), + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: 'var(--transparent-fg)', + opacity: 0.25, + } + }, + }, + yAxis: !hasData ? undefined : { + position: 'left', + name: this.widget ? undefined : $localize`:@@mining.min-fee-rate-cdf.y-axis:% of days`, + nameLocation: 'middle', + nameRotate: 90, + nameGap: 42, + nameTextStyle: { + color: 'rgb(110, 112, 121)', + fontSize: 12, + }, + min: 0, + max: 100, + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val): string => `${val}%`, + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: 'var(--transparent-fg)', + opacity: 0.25, + } + }, + type: 'value', + }, + series: !hasData ? undefined : [ + { + zlevel: 0, + name: curveLabel, + data: cdf, + type: 'line', + step: 'end', + symbol: 'none', + lineStyle: { + color: CURVE_COLOR, + width: 3, + }, + areaStyle: { + color: CURVE_COLOR, + opacity: 0.12, + }, + }, + { + zlevel: 1, + name: thresholdLabel, + type: 'line', + data: [[this.threshold, 0], [this.threshold, 100]], + symbol: 'none', + silent: true, + lineStyle: { + color: 'var(--fg)', + type: 'dashed', + width: 2, + }, + itemStyle: { + color: 'var(--fg)', + }, + }, + // Marker where the threshold crosses the curve. A separate series rather than a + // markPoint because MarkPointComponent is not registered in the echarts bundle. + { + zlevel: 2, + name: 'threshold-marker', + type: 'scatter', + data: [[this.threshold, this.percentBelow]], + symbolSize: 10, + silent: true, + itemStyle: { + color: 'var(--fg)', + borderColor: CURVE_COLOR, + borderWidth: 2, + }, + }, + ], + }; + } + + onChartInit(ec): void { + if (this.chartInstance !== undefined) { + return; + } + this.chartInstance = ec; + } + + isMobile(): boolean { + return (window.innerWidth <= 767.98); + } + + onSaveChart(): void { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + const now = new Date(); + // @ts-ignore + this.chartOptions.grid.bottom = 75; + this.chartOptions.backgroundColor = 'var(--active-bg)'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + }), `min-fee-rate-cdf-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } +} diff --git a/frontend/src/app/components/min-fee-rate-graph/min-fee-rate-graph.component.html b/frontend/src/app/components/min-fee-rate-graph/min-fee-rate-graph.component.html new file mode 100644 index 000000000..65cff3edf --- /dev/null +++ b/frontend/src/app/components/min-fee-rate-graph/min-fee-rate-graph.component.html @@ -0,0 +1,65 @@ + + +
+
+
+
+ Minimum Daily Fee Rate + +
+
Cheapest daily included effective fee rate (prioritized transactions excluded)
+
+
+ +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ No minimum daily fee rate data available yet. +
+ +
+
+
+
+
+ +
diff --git a/frontend/src/app/components/min-fee-rate-graph/min-fee-rate-graph.component.scss b/frontend/src/app/components/min-fee-rate-graph/min-fee-rate-graph.component.scss new file mode 100644 index 000000000..ad23fe65b --- /dev/null +++ b/frontend/src/app/components/min-fee-rate-graph/min-fee-rate-graph.component.scss @@ -0,0 +1,108 @@ +.full-container { + display: flex; + flex-direction: column; + padding: 0px 15px; + width: 100%; + height: calc(100vh - 225px); + min-height: 400px; + @media (min-width: 992px) { + height: calc(100vh - 150px); + } +} + +.chart-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + @media (min-width: 768px) { + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + } + + .heading { + min-width: 0; + + .title { + font-size: 18px; + line-height: 1.2; + @media (min-width: 465px) { + font-size: 20px; + } + .btn { + vertical-align: baseline; + } + } + + .subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--transparent-fg); + } + } + +} + +.chart-controls { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + margin: 12px 0 4px; + @media (min-width: 768px) { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + } + + .threshold-input { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + label { + margin: 0; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--transparent-fg); + white-space: nowrap; + } + input { + width: 90px; + } + } + + .formRadioGroup { + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } + } +} + +.chart { + display: flex; + flex: 1; + height: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } +} + +.chart-widget { + width: 100%; + height: 100%; + max-height: 238px; +} + +.no-data { + padding: 30px 0; +} diff --git a/frontend/src/app/components/min-fee-rate-graph/min-fee-rate-graph.component.ts b/frontend/src/app/components/min-fee-rate-graph/min-fee-rate-graph.component.ts new file mode 100644 index 000000000..32401d649 --- /dev/null +++ b/frontend/src/app/components/min-fee-rate-graph/min-fee-rate-graph.component.ts @@ -0,0 +1,318 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnInit } from '@angular/core'; +import { EChartsOption } from '@app/graphs/echarts'; +import { Observable, combineLatest, of } from 'rxjs'; +import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { SeoService } from '@app/services/seo.service'; +import { formatNumber } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { download } from '@app/shared/graphs.utils'; +import { StorageService } from '@app/services/storage.service'; +import { MiningService } from '@app/services/mining.service'; +import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; +import { StateService } from '@app/services/state.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MinFeeRateDay } from '@app/interfaces/node-api.interface'; +import { DEFAULT_MIN_FEE_RATE_THRESHOLD, MinFeeRateService } from '@app/services/min-fee-rate.service'; +import { chartColors } from '@app/app.constants'; + +// Days at or below the threshold are highlighted in green; the rest keep the warm +// default. Both come from the shared chart palette. +const HIGHLIGHT_COLOR = chartColors[9]; // '#43A047' +const DEFAULT_BAR_COLOR = chartColors[14]; // '#FB8C00' + +// The series is one point per day, so anything shorter than a month is degenerate. +const TIMESPANS = ['1m', '3m', '6m', '1y', '2y', '3y', 'all']; + +@Component({ + selector: 'app-min-fee-rate-graph', + templateUrl: './min-fee-rate-graph.component.html', + styleUrls: ['./min-fee-rate-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 99; + } + `], + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MinFeeRateGraphComponent implements OnInit { + @Input() widget = false; + + miningWindowPreference: string; + radioGroupForm: UntypedFormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + statsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + + data: MinFeeRateDay[] = []; + threshold = DEFAULT_MIN_FEE_RATE_THRESHOLD; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private minFeeRateService: MinFeeRateService, + private formBuilder: UntypedFormBuilder, + private storageService: StorageService, + private miningService: MiningService, + public stateService: StateService, + private router: Router, + private zone: NgZone, + private route: ActivatedRoute, + private cd: ChangeDetectorRef, + ) { + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1m', threshold: DEFAULT_MIN_FEE_RATE_THRESHOLD }); + this.radioGroupForm.controls.dateSpan.setValue('1m'); + } + + ngOnInit(): void { + if (this.widget) { + this.miningWindowPreference = '1m'; + } else { + this.seoService.setTitle($localize`:@@mining.min-fee-rate:Minimum Daily Fee Rate`); + this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.min-fee-rate:See the lowest fee rate that earned block inclusion on fee merit each day, excluding prioritized and accelerated transactions.`); + // miningWindowPreference is shared across every mining graph, so floor whatever it + // holds at the shortest timespan this chart offers. + this.miningWindowPreference = this.miningService.getDefaultTimespan('1m'); + } + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference, threshold: DEFAULT_MIN_FEE_RATE_THRESHOLD }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + if (!this.widget) { + this.route + .fragment + .subscribe((fragment) => { + if (TIMESPANS.indexOf(fragment) > -1) { + this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); + } + }); + } + + // Threshold changes only recolour the bars and move the marker, no refetch. + this.radioGroupForm.get('threshold').valueChanges.subscribe((value) => { + const parsed = parseFloat(value); + this.threshold = isNaN(parsed) || parsed < 0 ? 0 : parsed; + this.prepareChartOptions(); + this.cd.markForCheck(); + }); + + this.statsObservable$ = combineLatest([ + this.widget ? of(this.miningWindowPreference) : this.radioGroupForm.get('dateSpan').valueChanges.pipe(startWith(this.radioGroupForm.controls.dateSpan.value)), + ]).pipe( + switchMap(([timespan]) => { + if (!this.widget) { + this.storageService.setValue('miningWindowPreference', timespan); + } + this.timespan = timespan; + this.isLoading = true; + return this.minFeeRateService.getMinFeeRates$(timespan) + .pipe( + tap((response) => { + this.data = response.body || []; + this.prepareChartOptions(); + this.isLoading = false; + this.cd.markForCheck(); + }), + map((response) => { + return { + dayCount: parseInt(response.headers.get('x-total-count'), 10), + }; + }), + ); + }), + share(), + ); + } + + formatFeeRate(val: number): string { + return this.minFeeRateService.formatFeeRate(val); + } + + // Formatted in UTC: west of Greenwich a UTC-midnight month boundary would otherwise + // render as the previous month. Granularity follows the tick rather than the timespan, + // because ECharts sizes tick intervals by pixel density. + private formatAxisDate(value: number): string { + const date = new Date(value); + const isMonthStart = date.getUTCDate() === 1 && date.getUTCHours() === 0 && + date.getUTCMinutes() === 0 && date.getUTCSeconds() === 0; + return date.toLocaleDateString(this.locale, isMonthStart + ? { year: 'numeric', month: 'short', timeZone: 'UTC' } + : { month: 'short', day: 'numeric', timeZone: 'UTC' }); + } + + private formatTooltipDate(value: number): string { + return new Date(value).toLocaleDateString(this.locale, { + year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC', + }); + } + + prepareChartOptions(): void { + const seriesData = this.data.map(d => [d.timestamp * 1000, d.minRate, d.minHeight]); + const hasData = seriesData.length > 0; + + this.chartOptions = { + color: [DEFAULT_BAR_COLOR], + animation: false, + // Buckets are UTC calendar days, so ticks must land on UTC boundaries rather than + // the viewer's local midnight. + useUTC: true, + grid: { + right: this.widget ? 10 : 25, + left: this.widget ? 45 : 70, + bottom: this.widget ? 30 : 50, + top: 20, + }, + 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: 'var(--tooltip-grey)', + align: 'left', + }, + borderColor: '#000', + formatter: function (data: any): string { + if (data.length <= 0) { + return ''; + } + let tooltip = `${this.formatTooltipDate(+data[0].data[0])}
`; + tooltip += `${data[0].marker} ` + $localize`Min fee rate` + `: ${this.formatFeeRate(data[0].data[1])} sats/vByte
`; + tooltip += `` + $localize`At block: ${data[0].data[2]}` + ``; + return tooltip; + }.bind(this) + }, + // A time axis, not a category axis: days with no data must render as proportional + // gaps instead of collapsing into their neighbours. + xAxis: !hasData ? undefined : { + type: 'time', + axisLine: { onZero: true }, + axisLabel: { + formatter: (val: number): string => this.formatAxisDate(val), + align: 'center', + fontSize: 11, + lineHeight: 12, + hideOverlap: true, + padding: [0, 5], + }, + }, + yAxis: !hasData ? undefined : { + position: 'left', + name: this.widget ? undefined : $localize`:@@mining.min-fee-rate.axis:sat/vB`, + nameLocation: 'middle', + nameRotate: 90, + nameGap: 48, + nameTextStyle: { + color: 'rgb(110, 112, 121)', + fontSize: 12, + }, + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val): string => this.formatFeeRate(val), + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: 'var(--transparent-fg)', + opacity: 0.25, + } + }, + type: 'value', + }, + series: !hasData ? undefined : [{ + zlevel: 0, + name: 'Min fee rate', + data: seriesData, + type: 'bar', + large: true, + markLine: { + silent: true, + symbol: 'none', + lineStyle: { + color: 'var(--fg)', + type: 'dashed', + opacity: 1, + width: 2, + }, + data: [{ + yAxis: this.threshold, + label: { + show: true, + position: 'insideStartTop', + formatter: (): string => `${this.formatFeeRate(this.threshold)} sat/vB`, + color: 'var(--fg)', + fontSize: 11, + } + }], + } + }], + visualMap: !hasData ? undefined : { + show: false, + dimension: 1, + pieces: [ + { lte: this.threshold, color: HIGHLIGHT_COLOR }, + { gt: this.threshold, color: DEFAULT_BAR_COLOR }, + ], + }, + dataZoom: (this.widget || !hasData) ? undefined : [{ + type: 'inside', + realtime: true, + zoomLock: false, + maxSpan: 100, + minSpan: 5, + moveOnMouseMove: false, + }], + }; + } + + onChartInit(ec): void { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + + this.chartInstance.on('click', (e) => { + this.zone.run(() => { + const url = new RelativeUrlPipe(this.stateService).transform(`/block/${e.data[2]}`); + this.router.navigate([url]); + }); + }); + } + + isMobile(): boolean { + return (window.innerWidth <= 767.98); + } + + onSaveChart(): void { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + const now = new Date(); + // @ts-ignore + this.chartOptions.grid.bottom = 40; + this.chartOptions.backgroundColor = 'var(--active-bg)'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + excludeComponents: ['dataZoom'], + }), `min-fee-rate-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } +} diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 5994a1775..382e0ebfd 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -9,6 +9,8 @@ import { BlockFeesSubsidyGraphComponent } from '@components/block-fees-subsidy-g import { PriceChartComponent } from '@components/price-chart/price-chart.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'; +import { MinFeeRateGraphComponent } from '@components/min-fee-rate-graph/min-fee-rate-graph.component'; +import { MinFeeRateCdfGraphComponent } from '@components/min-fee-rate-cdf-graph/min-fee-rate-cdf-graph.component'; import { BlockSizesWeightsGraphComponent } from '@components/block-sizes-weights-graph/block-sizes-weights-graph.component'; import { FeeDistributionGraphComponent } from '@components/fee-distribution-graph/fee-distribution-graph.component'; import { IncomingTransactionsGraphComponent } from '@components/incoming-transactions-graph/incoming-transactions-graph.component'; @@ -70,6 +72,8 @@ import { CommonModule } from '@angular/common'; PriceChartComponent, BlockRewardsGraphComponent, BlockFeeRatesGraphComponent, + MinFeeRateGraphComponent, + MinFeeRateCdfGraphComponent, BlockSizesWeightsGraphComponent, FeeDistributionGraphComponent, IncomingTransactionsGraphComponent, diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index f0399f410..646275c05 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -2,6 +2,8 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component'; import { BlockFeeRatesGraphComponent } from '@components/block-fee-rates-graph/block-fee-rates-graph.component'; +import { MinFeeRateGraphComponent } from '@components/min-fee-rate-graph/min-fee-rate-graph.component'; +import { MinFeeRateCdfGraphComponent } from '@components/min-fee-rate-cdf-graph/min-fee-rate-cdf-graph.component'; import { BlockFeesGraphComponent } from '@components/block-fees-graph/block-fees-graph.component'; import { BlockFeesSubsidyGraphComponent } from '@components/block-fees-subsidy-graph/block-fees-subsidy-graph.component'; import { BlockRewardsGraphComponent } from '@components/block-rewards-graph/block-rewards-graph.component'; @@ -149,6 +151,16 @@ const routes: Routes = [ data: { networks: ['bitcoin'] }, component: BlockFeeRatesGraphComponent, }, + { + path: 'mining/min-fee-rate', + data: { networks: ['bitcoin'] }, + component: MinFeeRateGraphComponent, + }, + { + path: 'mining/min-fee-rate-cdf', + data: { networks: ['bitcoin'] }, + component: MinFeeRateCdfGraphComponent, + }, { path: 'mining/block-sizes-weights', data: { networks: ['bitcoin'] }, diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 92450246a..91db051c3 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -528,4 +528,11 @@ export interface ChainTip { export interface StaleTip extends ChainTip { stale: BlockExtended; canonical: BlockExtended; -} \ No newline at end of file +} + +export interface MinFeeRateDay { + minRate: number; + minHeight: number; + timestamp: number; // unix seconds, UTC midnight + usableBlockCount: number; +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index d05b02c66..ca3af257c 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -393,6 +393,13 @@ export class ApiService { ); } + getMinFeeRates$(interval: string | undefined) : Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/min-fee-rate` + + (interval !== undefined ? `/${interval}` : ''), { observe: 'response' } + ); + } + getHistoricalBlockSizesAndWeights$(interval: string | undefined) : Observable> { return this.httpClient.get( this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/sizes-weights` + diff --git a/frontend/src/app/services/min-fee-rate.service.ts b/frontend/src/app/services/min-fee-rate.service.ts new file mode 100644 index 000000000..dc2d079eb --- /dev/null +++ b/frontend/src/app/services/min-fee-rate.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@angular/core'; +import { HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiService } from '@app/services/api.service'; +import { MinFeeRateDay } from '@app/interfaces/node-api.interface'; + +// Bitcoin Core 30.0 lowered the default -minrelaytxfee to 0.1 sat/vB, which is the +// reference threshold both charts open on. +export const DEFAULT_MIN_FEE_RATE_THRESHOLD = 0.1; + +export interface MinFeeRateStats { + totalDays: number; + daysBelow: number; + percentBelow: number; +} + +@Injectable({ providedIn: 'root' }) +export class MinFeeRateService { + constructor(private apiService: ApiService) {} + + getMinFeeRates$(interval: string | undefined): Observable> { + return this.apiService.getMinFeeRates$(interval); + } + + getStats(data: MinFeeRateDay[], threshold: number): MinFeeRateStats { + const totalDays = data.length; + if (totalDays === 0) { + return { totalDays: 0, daysBelow: 0, percentBelow: 0 }; + } + const daysBelow = data.filter(d => d.minRate <= threshold).length; + return { + totalDays, + daysBelow, + percentBelow: (daysBelow / totalDays) * 100, + }; + } + + // Cumulative share of days whose minRate is <= a given fee rate. Duplicate rates are + // collapsed to a single step so the staircase stays monotonic and clean. + buildCdf(data: MinFeeRateDay[]): number[][] { + if (data.length === 0) { + return []; + } + const counts = new Map(); + for (const d of data) { + counts.set(d.minRate, (counts.get(d.minRate) || 0) + 1); + } + const rates = Array.from(counts.keys()).sort((a, b) => a - b); + const cdf: number[][] = []; + let cumulative = 0; + for (const rate of rates) { + cumulative += counts.get(rate); + cdf.push([rate, (cumulative / data.length) * 100]); + } + return cdf; + } + + // Minimum daily fee rates are often sub-1 sat/vB, so round adaptively: more decimals + // below 1 to keep values distinguishable, fewer as they grow. + formatFeeRate(val: number): string { + if (val >= 100) { + return val.toFixed(0); + } + if (val >= 10) { + return val.toFixed(1); + } + if (val >= 0.1) { + return val.toFixed(2); + } + if (val >= 0.01) { + return val.toFixed(3); + } + if (val > 0) { + return val.toFixed(4); + } + return '0'; + } +}