diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts index 0909d6100..cbd412068 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -45,6 +45,26 @@ class Mining { ); } + /** + * Get historical block sizes + */ + public async $getHistoricalBlockSizes(interval: string | null = null): Promise { + return await BlocksRepository.$getHistoricalBlockSizes( + this.getTimeRange(interval), + Common.getSqlInterval(interval) + ); + } + + /** + * Get historical block weights + */ + public async $getHistoricalBlockWeights(interval: string | null = null): Promise { + return await BlocksRepository.$getHistoricalBlockWeights( + this.getTimeRange(interval), + Common.getSqlInterval(interval) + ); + } + /** * Generate high level overview of the pool ranks and general stats */ diff --git a/backend/src/index.ts b/backend/src/index.ts index 8004e1ec9..8560064a9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -323,6 +323,7 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees/:interval', routes.$getHistoricalBlockFees) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/rewards/:interval', routes.$getHistoricalBlockRewards) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fee-rates/:interval', routes.$getHistoricalBlockFeeRates) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', routes.$getHistoricalBlockSizeAndWeight) ; } diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index e04080a9c..0792f130f 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -509,6 +509,56 @@ class BlocksRepository { throw e; } } + + /** + * Get the historical averaged block sizes + */ + public async $getHistoricalBlockSizes(div: number, interval: string | null): Promise { + try { + let query = `SELECT + CAST(AVG(height) as INT) as avg_height, + CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp, + CAST(AVG(size) as INT) as avg_size + FROM blocks`; + + if (interval !== null) { + query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`; + + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + /** + * Get the historical averaged block weights + */ + public async $getHistoricalBlockWeights(div: number, interval: string | null): Promise { + try { + let query = `SELECT + CAST(AVG(height) as INT) as avg_height, + CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp, + CAST(AVG(weight) as INT) as avg_weight + FROM blocks`; + + if (interval !== null) { + query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`; + + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } } export default new BlocksRepository(); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index d27fab683..72bf3b483 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -682,6 +682,24 @@ class Routes { } } + public async $getHistoricalBlockSizeAndWeight(req: Request, res: Response) { + try { + const blockSizes = await mining.$getHistoricalBlockSizes(req.params.interval ?? null); + const blockWeights = await mining.$getHistoricalBlockWeights(req.params.interval ?? null); + const blockCount = await BlocksRepository.$blockCount(null, null); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.header('X-total-count', blockCount.toString()); + res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); + res.json({ + sizes: blockSizes, + weights: blockWeights + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + public async getBlock(req: Request, res: Response) { try { const result = await bitcoinApi.$getBlock(req.params.hash); diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 13465a612..2eaf7e277 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -35,6 +35,7 @@ import { BlocksList } from './components/blocks-list/blocks-list.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'; +import { BlockSizesWeightsGraphComponent } from './components/block-sizes-weights-graph/block-sizes-weights-graph.component'; let routes: Routes = [ { @@ -131,6 +132,10 @@ let routes: Routes = [ path: 'mining/block-fee-rates', component: BlockFeeRatesGraphComponent, }, + { + path: 'mining/block-sizes-weights', + component: BlockSizesWeightsGraphComponent, + }, ], }, { @@ -261,6 +266,10 @@ let routes: Routes = [ path: 'mining/block-fee-rates', component: BlockFeeRatesGraphComponent, }, + { + path: 'mining/block-sizes-weights', + component: BlockSizesWeightsGraphComponent, + }, ] }, { @@ -389,6 +398,10 @@ let routes: Routes = [ path: 'mining/block-fee-rates', component: BlockFeeRatesGraphComponent, }, + { + path: 'mining/block-sizes-weights', + component: BlockSizesWeightsGraphComponent, + }, ] }, { diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 09e26e906..336cfead2 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -78,6 +78,7 @@ import { BlockRewardsGraphComponent } from './components/block-rewards-graph/blo import { BlockFeeRatesGraphComponent } from './components/block-fee-rates-graph/block-fee-rates-graph.component'; import { LoadingIndicatorComponent } from './components/loading-indicator/loading-indicator.component'; import { IndexingProgressComponent } from './components/indexing-progress/indexing-progress.component'; +import { BlockSizesWeightsGraphComponent } from './components/block-sizes-weights-graph/block-sizes-weights-graph.component'; @NgModule({ declarations: [ @@ -136,6 +137,7 @@ import { IndexingProgressComponent } from './components/indexing-progress/indexi BlockFeeRatesGraphComponent, LoadingIndicatorComponent, IndexingProgressComponent, + BlockSizesWeightsGraphComponent ], imports: [ BrowserModule.withServerTransition({ appId: 'serverApp' }), 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 101c27618..62b155fb5 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 @@ -278,9 +278,6 @@ export class BlockFeeRatesGraphComponent implements OnInit { this.chartInstance = ec; this.chartInstance.on('click', (e) => { - if (e.data.data === 9999) { // "Other" - return; - } this.zone.run(() => { if (['24h', '3d'].includes(this.timespan)) { const url = new RelativeUrlPipe(this.stateService).transform(`/block/${e.data[2]}`); diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts index f419472c9..b92d82120 100644 --- a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; import { EChartsOption, graphic } from 'echarts'; import { Observable } from 'rxjs'; import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; diff --git a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.html b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.html new file mode 100644 index 000000000..a02340a47 --- /dev/null +++ b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.html @@ -0,0 +1,52 @@ +
+ +
+ Block Sizes and Weights + + +
+
+ + + + + + + + + + +
+
+
+ +
+
+
+
+
+ +
diff --git a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss new file mode 100644 index 000000000..86c1f8ec3 --- /dev/null +++ b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss @@ -0,0 +1,135 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } +} + +.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 { + padding: 0px 15px; + width: 100%; + min-height: 500px; + height: calc(100% - 150px); + @media (max-width: 992px) { + height: 100%; + padding-bottom: 100px; + }; +} + +.chart { + width: 100%; + height: 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: 270px; +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 1130px) { + position: relative; + top: -65px; + } + @media (min-width: 830px) and (max-width: 1130px) { + position: relative; + top: 0px; + } + @media (min-width: 830px) { + flex-direction: row; + float: right; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} + +.pool-distribution { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: #4a68b9; + } + .card-text { + font-size: 18px; + span { + color: #ffffff66; + font-size: 12px; + } + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} \ No newline at end of file diff --git a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts new file mode 100644 index 000000000..1f72c042b --- /dev/null +++ b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts @@ -0,0 +1,329 @@ +import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit, HostBinding, NgZone } from '@angular/core'; +import { EChartsOption} from 'echarts'; +import { Observable } from 'rxjs'; +import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { ApiService } from 'src/app/services/api.service'; +import { SeoService } from 'src/app/services/seo.service'; +import { formatNumber } from '@angular/common'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { StorageService } from 'src/app/services/storage.service'; +import { MiningService } from 'src/app/services/mining.service'; +import { StateService } from 'src/app/services/state.service'; +import { Router } from '@angular/router'; +import { download } from 'src/app/shared/graphs.utils'; + +@Component({ + selector: 'app-block-sizes-weights-graph', + templateUrl: './block-sizes-weights-graph.component.html', + styleUrls: ['./block-sizes-weights-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BlockSizesWeightsGraphComponent implements OnInit { + @Input() right: number | string = 45; + @Input() left: number | string = 75; + + miningWindowPreference: string; + radioGroupForm: FormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + @HostBinding('attr.dir') dir = 'ltr'; + + blockSizesWeightsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private apiService: ApiService, + private formBuilder: FormBuilder, + private storageService: StorageService, + private miningService: MiningService, + private stateService: StateService, + private router: Router, + private zone: NgZone, + ) { + } + + ngOnInit(): void { + let firstRun = true; + + this.seoService.setTitle($localize`:@@mining.hashrate-difficulty:Hashrate and Weight`); + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + this.blockSizesWeightsObservable$ = this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith(this.miningWindowPreference), + switchMap((timespan) => { + this.timespan = timespan; + if (!firstRun) { + this.storageService.setValue('miningWindowPreference', timespan); + } + firstRun = false; + this.miningWindowPreference = timespan; + this.isLoading = true; + return this.apiService.getHistoricalBlockSizesAndWeights$(timespan) + .pipe( + tap((response) => { + const data = response.body; + this.prepareChartOptions({ + sizes: data.sizes.map(val => [val.timestamp * 1000, val.avg_size / 1000000, val.avg_height]), + weights: data.weights.map(val => [val.timestamp * 1000, val.avg_weight / 1000000, val.avg_height]), + }); + this.isLoading = false; + }), + map((response) => { + return { + blockCount: parseInt(response.headers.get('x-total-count'), 10), + }; + }), + ); + }), + share() + ); + } + + prepareChartOptions(data) { + let title: object; + if (data.sizes.length === 0) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: `Indexing in progess`, + left: 'center', + top: 'center' + }; + } + + this.chartOptions = { + title: title, + animation: false, + color: [ + '#FDD835', + '#D81B60', + ], + grid: { + top: 30, + bottom: 70, + right: this.right, + left: this.left, + }, + 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: (ticks) => { + let sizeString = ''; + let weightString = ''; + + for (const tick of ticks) { + if (tick.seriesIndex === 0) { // Size + sizeString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.2-2')} MB`; + } else if (tick.seriesIndex === 1) { // Weight + weightString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.2-2')} MWU`; + } + } + + const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); + + let tooltip = `${date}
+ ${sizeString}
+ ${weightString}`; + + if (['24h', '3d'].includes(this.timespan)) { + tooltip += `
At block: ${ticks[0].data[2]}`; + } else { + tooltip += `
Around block ${ticks[0].data[2]}`; + } + + return tooltip; + } + }, + xAxis: data.sizes.length === 0 ? undefined : { + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + axisLabel: { + hideOverlap: true, + } + }, + legend: data.sizes.length === 0 ? undefined : { + padding: 10, + data: [ + { + name: 'Size', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Weight', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + ], + selected: JSON.parse(this.storageService.getValue('sizes_weights_legend')) ?? { + 'Size': true, + 'Weight': true, + } + }, + yAxis: data.sizes.length === 0 ? undefined : [ + { + type: 'value', + position: 'left', + min: (value) => { + return value.min * 0.9; + }, + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${Math.round(val * 100) / 100} MWU`; + } + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: '#ffffff66', + opacity: 0.25, + } + }, + } + ], + series: data.sizes.length === 0 ? [] : [ + { + zlevel: 1, + name: 'Size', + showSymbol: false, + symbol: 'none', + data: data.sizes, + type: 'line', + lineStyle: { + width: 2, + }, + markLine: { + silent: true, + symbol: 'none', + lineStyle: { + type: 'solid', + color: '#ffffff66', + opacity: 1, + width: 1, + }, + data: [{ + yAxis: 1, + label: { + position: 'end', + show: true, + color: '#ffffff', + formatter: `1 MB` + } + }], + } + }, + { + zlevel: 1, + yAxisIndex: 0, + name: 'Weight', + showSymbol: false, + symbol: 'none', + data: data.weights, + type: 'line', + lineStyle: { + width: 2, + } + } + ], + dataZoom: [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 5, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: 20, + right: 15, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + areaStyle: { + opacity: 0, + } + }, + }], + }; + } + + onChartInit(ec) { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + + this.chartInstance.on('legendselectchanged', (e) => { + this.storageService.setValue('sizes_weights_legend', JSON.stringify(e.selected)); + }); + } + + 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'], + }), `block-sizes-weights-${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/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index dce79ad97..a32829d6b 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -28,6 +28,10 @@ [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" i18n="mining.block-rewards"> Block Rewards + + Block Sizes and Weights + diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index b892e16ff..982461ad1 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -189,6 +189,13 @@ export class ApiService { ); } + getHistoricalBlockSizesAndWeights$(interval: string | undefined) : Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/sizes-weights` + + (interval !== undefined ? `/${interval}` : ''), { observe: 'response' } + ); + } + getRewardStats$(blockCount: number = 144): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); }