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 @@
+
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