diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts index 3c06fb023..fa2f1a1ec 100644 --- a/frontend/src/app/lightning/lightning.module.ts +++ b/frontend/src/app/lightning/lightning.module.ts @@ -15,6 +15,7 @@ import { ChannelBoxComponent } from './channel/channel-box/channel-box.component import { ClosingTypeComponent } from './channel/closing-type/closing-type.component'; import { LightningStatisticsChartComponent } from './statistics-chart/lightning-statistics-chart.component'; import { NodeStatisticsChartComponent } from './node-statistics-chart/node-statistics-chart.component'; +import { NodeFeeChartComponent } from './node-fee-chart/node-fee-chart.component'; import { GraphsModule } from '../graphs/graphs.module'; import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component'; import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component'; @@ -38,6 +39,7 @@ import { GroupComponent } from './group/group.component'; NodesListComponent, NodeStatisticsComponent, NodeStatisticsChartComponent, + NodeFeeChartComponent, NodeComponent, ChannelsListComponent, ChannelComponent, @@ -73,6 +75,7 @@ import { GroupComponent } from './group/group.component'; NodesListComponent, NodeStatisticsComponent, NodeStatisticsChartComponent, + NodeFeeChartComponent, NodeComponent, ChannelsListComponent, ChannelComponent, diff --git a/frontend/src/app/lightning/node-fee-chart/node-fee-chart.component.html b/frontend/src/app/lightning/node-fee-chart/node-fee-chart.component.html new file mode 100644 index 000000000..c8f674f11 --- /dev/null +++ b/frontend/src/app/lightning/node-fee-chart/node-fee-chart.component.html @@ -0,0 +1,7 @@ +
+

Fee distribution

+
+
+
d +
+
diff --git a/frontend/src/app/lightning/node-fee-chart/node-fee-chart.component.scss b/frontend/src/app/lightning/node-fee-chart/node-fee-chart.component.scss new file mode 100644 index 000000000..d738daa81 --- /dev/null +++ b/frontend/src/app/lightning/node-fee-chart/node-fee-chart.component.scss @@ -0,0 +1,5 @@ +.full-container { + margin-top: 25px; + margin-bottom: 25px; + min-height: 100%; +} diff --git a/frontend/src/app/lightning/node-fee-chart/node-fee-chart.component.ts b/frontend/src/app/lightning/node-fee-chart/node-fee-chart.component.ts new file mode 100644 index 000000000..f0370d3e1 --- /dev/null +++ b/frontend/src/app/lightning/node-fee-chart/node-fee-chart.component.ts @@ -0,0 +1,265 @@ +import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; +import { EChartsOption } from 'echarts'; +import { switchMap } from 'rxjs/operators'; +import { download } from '../../shared/graphs.utils'; +import { LightningApiService } from '../lightning-api.service'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; + +@Component({ + selector: 'app-node-fee-chart', + templateUrl: './node-fee-chart.component.html', + styleUrls: ['./node-fee-chart.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], +}) +export class NodeFeeChartComponent implements OnInit { + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + @HostBinding('attr.dir') dir = 'ltr'; + + isLoading = true; + chartInstance: any = undefined; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private lightningApiService: LightningApiService, + private activatedRoute: ActivatedRoute, + private amountShortenerPipe: AmountShortenerPipe, + ) { + } + + ngOnInit(): void { + + this.activatedRoute.paramMap + .pipe( + switchMap((params: ParamMap) => { + this.isLoading = true; + return this.lightningApiService.getNodeFeeHistogram$(params.get('public_key')); + }), + ).subscribe((data) => { + if (data && data.incoming && data.outgoing) { + const outgoingHistogram = this.bucketsToHistogram(data.outgoing); + const incomingHistogram = this.bucketsToHistogram(data.incoming); + this.prepareChartOptions(outgoingHistogram, incomingHistogram); + } + this.isLoading = false; + }); + } + + bucketsToHistogram(buckets): { label: string, count: number, capacity: number}[] { + const histogram = []; + let increment = 1; + let lower = -increment; + let upper = 0; + + let nullBucket; + if (buckets.length && buckets[0] && buckets[0].bucket == null) { + nullBucket = buckets.shift(); + } + + while (upper <= 5000) { + let bucket; + if (buckets.length && buckets[0] && upper >= Number(buckets[0].bucket)) { + bucket = buckets.shift(); + } + histogram.push({ + label: upper === 0 ? '0 ppm' : `${lower} - ${upper} ppm`, + count: Number(bucket?.count || 0) + (upper === 0 ? Number(nullBucket?.count || 0) : 0), + capacity: Number(bucket?.capacity || 0) + (upper === 0 ? Number(nullBucket?.capacity || 0) : 0), + }); + + if (upper >= increment * 10) { + increment *= 10; + lower = increment; + upper = increment + increment; + } else { + lower += increment; + upper += increment; + } + } + const rest = buckets.reduce((acc, bucket) => { + acc.count += Number(bucket.count); + acc.capacity += Number(bucket.capacity); + return acc; + }, { count: 0, capacity: 0 }); + histogram.push({ + label: `5000+ ppm`, + count: rest.count, + capacity: rest.capacity, + }); + return histogram; + } + + prepareChartOptions(outgoingData, incomingData): void { + let title: object; + if (outgoingData.length === 0) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: $localize`No data to display yet. Try again later.`, + left: 'center', + top: 'center' + }; + } + + this.chartOptions = { + title: outgoingData.length === 0 ? title : undefined, + animation: false, + grid: { + top: 30, + bottom: 20, + right: 20, + left: 65, + }, + 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): string => { + return ` + ${ticks[0].data.label}
+
+ ${ticks[0].marker} Outgoing
+ Capacity: ${this.amountShortenerPipe.transform(ticks[0].data.capacity, 2, undefined, true)} sats
+ Channels: ${ticks[0].data.count}
+
+ ${ticks[1].marker} Incoming
+ Capacity: ${this.amountShortenerPipe.transform(ticks[1].data.capacity, 2, undefined, true)} sats
+ Channels: ${ticks[1].data.count}
+ `; + } + }, + xAxis: outgoingData.length === 0 ? undefined : { + type: 'category', + axisLine: { onZero: true }, + axisLabel: { + align: 'center', + fontSize: 11, + lineHeight: 12, + hideOverlap: true, + padding: [0, 5], + }, + data: outgoingData.map(bucket => bucket.label) + }, + legend: outgoingData.length === 0 ? undefined : { + padding: 10, + data: [ + { + name: 'Outgoing Fees', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Incoming Fees', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + ], + }, + yAxis: outgoingData.length === 0 ? undefined : [ + { + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${this.amountShortenerPipe.transform(Math.abs(val), 2, undefined, true)} sats`; + } + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: '#ffffff66', + opacity: 0.25, + } + }, + }, + ], + series: outgoingData.length === 0 ? undefined : [ + { + zlevel: 0, + name: 'Outgoing Fees', + data: outgoingData.map(bucket => ({ + value: bucket.capacity, + label: bucket.label, + capacity: bucket.capacity, + count: bucket.count, + })), + type: 'bar', + barWidth: '90%', + barMaxWidth: 50, + stack: 'fees', + }, + { + zlevel: 0, + name: 'Incoming Fees', + data: incomingData.map(bucket => ({ + value: -bucket.capacity, + label: bucket.label, + capacity: bucket.capacity, + count: bucket.count, + })), + type: 'bar', + barWidth: '90%', + barMaxWidth: 50, + stack: 'fees', + }, + ], + }; + } + + onChartInit(ec) { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + + onSaveChart() { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + // @ts-ignore + this.chartOptions.grid.bottom = 40; + this.chartOptions.backgroundColor = '#11131f'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + }), `node-fee-chart.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } +} diff --git a/frontend/src/app/lightning/node/node.component.html b/frontend/src/app/lightning/node/node.component.html index c6e3e794c..7d506e6b0 100644 --- a/frontend/src/app/lightning/node/node.component.html +++ b/frontend/src/app/lightning/node/node.component.html @@ -140,6 +140,8 @@ + +

Open channels