diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts
index 590ed1f20..c3b3f8124 100644
--- a/backend/src/api/explorer/nodes.api.ts
+++ b/backend/src/api/explorer/nodes.api.ts
@@ -93,6 +93,37 @@ class NodesApi {
throw e;
}
}
+
+ public async $getNodesAsShare() {
+ try {
+ let query = `SELECT names, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity
+ FROM nodes
+ JOIN geo_names ON geo_names.id = nodes.as_number
+ JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key
+ GROUP BY as_number
+ ORDER BY COUNT(DISTINCT nodes.public_key) DESC
+ `;
+ const [nodesCountPerAS]: any = await DB.query(query);
+
+ query = `SELECT COUNT(*) as total FROM nodes WHERE as_number IS NOT NULL`;
+ const [nodesWithAS]: any = await DB.query(query);
+
+ const nodesPerAs: any[] = [];
+ for (const as of nodesCountPerAS) {
+ nodesPerAs.push({
+ name: JSON.parse(as.names),
+ count: as.nodesCount,
+ share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100,
+ capacity: as.capacity,
+ })
+ }
+
+ return nodesPerAs;
+ } catch (e) {
+ logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`);
+ throw e;
+ }
+ }
}
export default new NodesApi();
diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts
index 6c79c8201..d2960155b 100644
--- a/backend/src/api/explorer/nodes.routes.ts
+++ b/backend/src/api/explorer/nodes.routes.ts
@@ -8,6 +8,7 @@ class NodesRoutes {
app
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes)
+ .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/asShare', this.$getNodesAsShare)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)
;
@@ -56,6 +57,18 @@ class NodesRoutes {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
+
+ private async $getNodesAsShare(req: Request, res: Response) {
+ try {
+ const nodesPerAs = await nodesApi.$getNodesAsShare();
+ res.header('Pragma', 'public');
+ res.header('Cache-control', 'public');
+ res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
+ res.json(nodesPerAs);
+ } catch (e) {
+ res.status(500).send(e instanceof Error ? e.message : e);
+ }
+ }
}
export default new NodesRoutes();
diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html
index 6f93676f6..ab3671459 100644
--- a/frontend/src/app/components/graphs/graphs.component.html
+++ b/frontend/src/app/components/graphs/graphs.component.html
@@ -34,6 +34,8 @@
i18n="lightning.nodes-networks">Nodes per network
Network capacity
+ Lightning nodes per ISP
diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts
index 8bf531d80..1cff68b8a 100644
--- a/frontend/src/app/graphs/graphs.routing.module.ts
+++ b/frontend/src/app/graphs/graphs.routing.module.ts
@@ -20,6 +20,7 @@ import { TelevisionComponent } from '../components/television/television.compone
import { DashboardComponent } from '../dashboard/dashboard.component';
import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component';
+import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.component';
const browserWindow = window || {};
// @ts-ignore
@@ -99,6 +100,10 @@ const routes: Routes = [
path: 'lightning/capacity',
component: LightningStatisticsChartComponent,
},
+ {
+ path: 'lightning/nodes-per-isp',
+ component: NodesPerAsChartComponent,
+ },
{
path: '',
redirectTo: 'mempool',
diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts
index 9146975e4..1cf9992f6 100644
--- a/frontend/src/app/lightning/lightning.module.ts
+++ b/frontend/src/app/lightning/lightning.module.ts
@@ -18,6 +18,7 @@ import { NodeStatisticsChartComponent } from './node-statistics-chart/node-stati
import { GraphsModule } from '../graphs/graphs.module';
import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
+import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.component';
@NgModule({
declarations: [
LightningDashboardComponent,
@@ -33,6 +34,7 @@ import { ChannelsStatisticsComponent } from './channels-statistics/channels-stat
LightningStatisticsChartComponent,
NodesNetworksChartComponent,
ChannelsStatisticsComponent,
+ NodesPerAsChartComponent,
],
imports: [
CommonModule,
diff --git a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html
new file mode 100644
index 000000000..3658eea18
--- /dev/null
+++ b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Rank |
+ Name |
+ Share |
+ Nodes |
+ Capacity |
+
+
+
+
+ {{ asEntry.rank }} |
+ {{ asEntry.name }} |
+ {{ asEntry.share }}% |
+ {{ asEntry.count }} |
+ |
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.scss b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.scss
new file mode 100644
index 000000000..832880122
--- /dev/null
+++ b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.scss
@@ -0,0 +1,36 @@
+.card-header {
+ border-bottom: 0;
+ font-size: 18px;
+ @media (min-width: 465px) {
+ font-size: 20px;
+ }
+}
+
+.full-container {
+ padding: 0px 15px;
+ width: 100%;
+ height: calc(100% - 140px);
+ @media (max-width: 992px) {
+ height: calc(100% - 190px);
+ };
+ @media (max-width: 575px) {
+ height: calc(100% - 230px);
+ };
+}
+
+.chart {
+ max-height: 400px;
+ @media (max-width: 767.98px) {
+ max-height: 230px;
+ margin-top: -35px;
+ }
+}
+
+.bottom-padding {
+ @media (max-width: 992px) {
+ padding-bottom: 65px
+ };
+ @media (max-width: 576px) {
+ padding-bottom: 65px
+ };
+}
diff --git a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts
new file mode 100644
index 000000000..32fad6459
--- /dev/null
+++ b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts
@@ -0,0 +1,214 @@
+import { ChangeDetectionStrategy, Component, OnInit, HostBinding } from '@angular/core';
+import { EChartsOption, PieSeriesOption } from 'echarts';
+import { map, Observable, share, tap } from 'rxjs';
+import { chartColors } from 'src/app/app.constants';
+import { ApiService } from 'src/app/services/api.service';
+import { SeoService } from 'src/app/services/seo.service';
+import { download } from 'src/app/shared/graphs.utils';
+import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
+
+@Component({
+ selector: 'app-nodes-per-as-chart',
+ templateUrl: './nodes-per-as-chart.component.html',
+ styleUrls: ['./nodes-per-as-chart.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class NodesPerAsChartComponent implements OnInit {
+ miningWindowPreference: string;
+
+ isLoading = true;
+ chartOptions: EChartsOption = {};
+ chartInitOptions = {
+ renderer: 'svg',
+ };
+ timespan = '';
+ chartInstance: any = undefined;
+
+ @HostBinding('attr.dir') dir = 'ltr';
+
+ nodesPerAsObservable$: Observable;
+
+ constructor(
+ private apiService: ApiService,
+ private seoService: SeoService,
+ private amountShortenerPipe: AmountShortenerPipe
+ ) {
+ }
+
+ ngOnInit(): void {
+ this.seoService.setTitle($localize`Lightning nodes per ISP`);
+
+ this.nodesPerAsObservable$ = this.apiService.getNodesPerAs()
+ .pipe(
+ tap(data => {
+ this.isLoading = false;
+ this.prepareChartOptions(data);
+ }),
+ map(data => {
+ for (let i = 0; i < data.length; ++i) {
+ data[i].rank = i + 1;
+ }
+ return data.slice(0, 100);
+ }),
+ share()
+ );
+ }
+
+ generateChartSerieData(as) {
+ const shareThreshold = this.isMobile() ? 2 : 1;
+ const data: object[] = [];
+ let totalShareOther = 0;
+ let totalNodeOther = 0;
+
+ let edgeDistance: string | number = '10%';
+ if (this.isMobile()) {
+ edgeDistance = 0;
+ }
+
+ as.forEach((as) => {
+ if (as.share < shareThreshold) {
+ totalShareOther += as.share;
+ totalNodeOther += as.count;
+ return;
+ }
+ data.push({
+ value: as.share,
+ name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`),
+ label: {
+ overflow: 'truncate',
+ color: '#b1b1b1',
+ alignTo: 'edge',
+ edgeDistance: edgeDistance,
+ },
+ tooltip: {
+ show: !this.isMobile(),
+ backgroundColor: 'rgba(17, 19, 31, 1)',
+ borderRadius: 4,
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
+ textStyle: {
+ color: '#b1b1b1',
+ },
+ borderColor: '#000',
+ formatter: () => {
+ return `${as.name} (${as.share}%)
` +
+ $localize`${as.count.toString()} nodes
` +
+ $localize`${this.amountShortenerPipe.transform(as.capacity / 100000000, 2)} BTC capacity`
+ ;
+ }
+ },
+ data: as.slug,
+ } as PieSeriesOption);
+ });
+
+ // 'Other'
+ data.push({
+ itemStyle: {
+ color: 'grey',
+ },
+ value: totalShareOther,
+ name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`),
+ label: {
+ overflow: 'truncate',
+ color: '#b1b1b1',
+ alignTo: 'edge',
+ edgeDistance: edgeDistance
+ },
+ tooltip: {
+ backgroundColor: 'rgba(17, 19, 31, 1)',
+ borderRadius: 4,
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
+ textStyle: {
+ color: '#b1b1b1',
+ },
+ borderColor: '#000',
+ formatter: () => {
+ return `${'Other'} (${totalShareOther.toFixed(2)}%)
` +
+ totalNodeOther.toString() + ` nodes`;
+ }
+ },
+ } as PieSeriesOption);
+
+ return data;
+ }
+
+ prepareChartOptions(as) {
+ let pieSize = ['20%', '80%']; // Desktop
+ if (this.isMobile()) {
+ pieSize = ['15%', '60%'];
+ }
+
+ this.chartOptions = {
+ color: chartColors,
+ tooltip: {
+ trigger: 'item',
+ textStyle: {
+ align: 'left',
+ }
+ },
+ series: [
+ {
+ zlevel: 0,
+ minShowLabelAngle: 3.6,
+ name: 'Mining pool',
+ type: 'pie',
+ radius: pieSize,
+ data: this.generateChartSerieData(as),
+ labelLine: {
+ lineStyle: {
+ width: 2,
+ },
+ length: this.isMobile() ? 1 : 20,
+ length2: this.isMobile() ? 1 : undefined,
+ },
+ label: {
+ fontSize: 14,
+ },
+ itemStyle: {
+ borderRadius: 1,
+ borderWidth: 1,
+ borderColor: '#000',
+ },
+ emphasis: {
+ itemStyle: {
+ shadowBlur: 40,
+ shadowColor: 'rgba(0, 0, 0, 0.75)',
+ },
+ labelLine: {
+ lineStyle: {
+ width: 4,
+ }
+ }
+ }
+ }
+ ],
+ };
+ }
+
+ isMobile() {
+ return (window.innerWidth <= 767.98);
+ }
+
+ onChartInit(ec) {
+ if (this.chartInstance !== undefined) {
+ return;
+ }
+ this.chartInstance = ec;
+ }
+
+ onSaveChart() {
+ const now = new Date();
+ this.chartOptions.backgroundColor = '#11131f';
+ this.chartInstance.setOption(this.chartOptions);
+ download(this.chartInstance.getDataURL({
+ pixelRatio: 2,
+ excludeComponents: ['dataZoom'],
+ }), `ln-nodes-per-as-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`);
+ this.chartOptions.backgroundColor = 'none';
+ this.chartInstance.setOption(this.chartOptions);
+ }
+
+ isEllipsisActive(e) {
+ return (e.offsetWidth < e.scrollWidth);
+ }
+}
+
diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts
index 8e3da7e09..48f23a94f 100644
--- a/frontend/src/app/services/api.service.ts
+++ b/frontend/src/app/services/api.service.ts
@@ -251,4 +251,7 @@ export class ApiService {
return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
}
+ getNodesPerAs(): Observable {
+ return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/asShare');
+ }
}