diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts
index e6ed68115..831eeba45 100644
--- a/backend/src/api/explorer/nodes.api.ts
+++ b/backend/src/api/explorer/nodes.api.ts
@@ -186,6 +186,39 @@ class NodesApi {
throw e;
}
}
+
+ public async $getNodesCountries() {
+ try {
+ let query = `SELECT geo_names.names as names, geo_names_iso.names as iso_code, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity
+ FROM nodes
+ JOIN geo_names ON geo_names.id = nodes.country_id AND geo_names.type = 'country'
+ JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
+ JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key
+ GROUP BY country_id
+ ORDER BY COUNT(DISTINCT nodes.public_key) DESC
+ `;
+ const [nodesCountPerCountry]: any = await DB.query(query);
+
+ query = `SELECT COUNT(*) as total FROM nodes WHERE country_id IS NOT NULL`;
+ const [nodesWithAS]: any = await DB.query(query);
+
+ const nodesPerCountry: any[] = [];
+ for (const country of nodesCountPerCountry) {
+ nodesPerCountry.push({
+ name: JSON.parse(country.names),
+ iso: country.iso_code,
+ count: country.nodesCount,
+ share: Math.floor(country.nodesCount / nodesWithAS[0].total * 10000) / 100,
+ capacity: country.capacity,
+ })
+ }
+
+ return nodesPerCountry;
+ } 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 9840ddbeb..bbc8efb5a 100644
--- a/backend/src/api/explorer/nodes.routes.ts
+++ b/backend/src/api/explorer/nodes.routes.ts
@@ -13,6 +13,7 @@ class NodesRoutes {
.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/isp', this.$getNodesISP)
+ .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/countries', this.$getNodesCountries)
.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)
;
@@ -128,6 +129,18 @@ class NodesRoutes {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
+
+ private async $getNodesCountries(req: Request, res: Response) {
+ try {
+ const nodesPerAs = await nodesApi.$getNodesCountries();
+ 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 ab3671459..3dbaee25e 100644
--- a/frontend/src/app/components/graphs/graphs.component.html
+++ b/frontend/src/app/components/graphs/graphs.component.html
@@ -36,6 +36,8 @@
i18n="lightning.capacity">Network capacity
Lightning nodes per ISP
+ Lightning nodes per country
diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts
index 20d7b6024..a853ad576 100644
--- a/frontend/src/app/graphs/graphs.routing.module.ts
+++ b/frontend/src/app/graphs/graphs.routing.module.ts
@@ -21,6 +21,7 @@ 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 { NodesPerISPChartComponent } from '../lightning/nodes-per-isp-chart/nodes-per-isp-chart.component';
+import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
const browserWindow = window || {};
// @ts-ignore
@@ -104,6 +105,10 @@ const routes: Routes = [
path: 'lightning/nodes-per-isp',
component: NodesPerISPChartComponent,
},
+ {
+ path: 'lightning/nodes-per-country',
+ component: NodesPerCountryChartComponent,
+ },
{
path: '',
redirectTo: 'mempool',
diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts
index b5fc0da14..4e2633b65 100644
--- a/frontend/src/app/lightning/lightning.module.ts
+++ b/frontend/src/app/lightning/lightning.module.ts
@@ -21,6 +21,7 @@ import { ChannelsStatisticsComponent } from './channels-statistics/channels-stat
import { NodesPerISPChartComponent } from './nodes-per-isp-chart/nodes-per-isp-chart.component';
import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
+import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
@NgModule({
declarations: [
LightningDashboardComponent,
@@ -39,6 +40,7 @@ import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
NodesPerISPChartComponent,
NodesPerCountry,
NodesPerISP,
+ NodesPerCountryChartComponent,
],
imports: [
CommonModule,
diff --git a/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.html b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.html
new file mode 100644
index 000000000..96735f4df
--- /dev/null
+++ b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Rank |
+ Name |
+ Share |
+ Nodes |
+ Capacity |
+
+
+
+
+ {{ country.rank }} |
+ {{ country.name.en }} |
+ {{ country.share }}% |
+ {{ country.count }} |
+
+ 100000000; else smallchannel" [satoshis]="country.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true">
+
+ {{ country.capacity | amountShortener: 1 }}
+ sats
+
+ |
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.scss b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.scss
new file mode 100644
index 000000000..c2c94cac0
--- /dev/null
+++ b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.scss
@@ -0,0 +1,81 @@
+.sats {
+ color: #ffffff66;
+ font-size: 12px;
+ top: 0px;
+}
+
+.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
+ };
+}
+
+.rank {
+ width: 20%;
+ @media (max-width: 576px) {
+ display: none
+ }
+}
+
+.name {
+ width: 20%;
+ @media (max-width: 576px) {
+ width: 80%;
+ max-width: 150px;
+ padding-left: 0;
+ padding-right: 0;
+ }
+}
+
+.share {
+ width: 20%;
+ @media (max-width: 576px) {
+ display: none
+ }
+}
+
+.nodes {
+ width: 20%;
+ @media (max-width: 576px) {
+ width: 10%;
+ }
+}
+
+.capacity {
+ width: 20%;
+ @media (max-width: 576px) {
+ width: 10%;
+ max-width: 100px;
+ }
+}
diff --git a/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts
new file mode 100644
index 000000000..7e447933d
--- /dev/null
+++ b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts
@@ -0,0 +1,231 @@
+import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core';
+import { Router } from '@angular/router';
+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 { StateService } from 'src/app/services/state.service';
+import { download } from 'src/app/shared/graphs.utils';
+import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
+import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
+
+@Component({
+ selector: 'app-nodes-per-country-chart',
+ templateUrl: './nodes-per-country-chart.component.html',
+ styleUrls: ['./nodes-per-country-chart.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class NodesPerCountryChartComponent implements OnInit {
+ miningWindowPreference: string;
+
+ isLoading = true;
+ chartOptions: EChartsOption = {};
+ chartInitOptions = {
+ renderer: 'svg',
+ };
+ timespan = '';
+ chartInstance: any = undefined;
+
+ @HostBinding('attr.dir') dir = 'ltr';
+
+ nodesPerCountryObservable$: Observable;
+
+ constructor(
+ private apiService: ApiService,
+ private seoService: SeoService,
+ private amountShortenerPipe: AmountShortenerPipe,
+ private zone: NgZone,
+ private stateService: StateService,
+ private router: Router,
+ ) {
+ }
+
+ ngOnInit(): void {
+ this.seoService.setTitle($localize`Lightning nodes per country`);
+
+ this.nodesPerCountryObservable$ = this.apiService.getNodesPerCountry()
+ .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(country) {
+ 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;
+ }
+
+ country.forEach((country) => {
+ if (country.share < shareThreshold) {
+ totalShareOther += country.share;
+ totalNodeOther += country.count;
+ return;
+ }
+ data.push({
+ value: country.share,
+ name: country.name.en + (this.isMobile() ? `` : ` (${country.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 `${country.name.en} (${country.share}%)
` +
+ $localize`${country.count.toString()} nodes
` +
+ $localize`${this.amountShortenerPipe.transform(country.capacity / 100000000, 2)} BTC capacity`
+ ;
+ }
+ },
+ data: country.iso,
+ } 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(country) {
+ let pieSize = ['20%', '80%']; // Desktop
+ if (this.isMobile()) {
+ pieSize = ['15%', '60%'];
+ }
+
+ this.chartOptions = {
+ animation: false,
+ color: chartColors,
+ tooltip: {
+ trigger: 'item',
+ textStyle: {
+ align: 'left',
+ }
+ },
+ series: [
+ {
+ zlevel: 0,
+ minShowLabelAngle: 3.6,
+ name: 'Mining pool',
+ type: 'pie',
+ radius: pieSize,
+ data: this.generateChartSerieData(country),
+ 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;
+
+ this.chartInstance.on('click', (e) => {
+ if (e.data.data === 9999) { // "Other"
+ return;
+ }
+ this.zone.run(() => {
+ const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/country/${e.data.data}`);
+ this.router.navigate([url]);
+ });
+ });
+ }
+
+ onSaveChart() {
+ const now = new Date();
+ this.chartOptions.backgroundColor = '#11131f';
+ this.chartInstance.setOption(this.chartOptions);
+ download(this.chartInstance.getDataURL({
+ pixelRatio: 2,
+ excludeComponents: ['dataZoom'],
+ }), `lightning-nodes-per-country-${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/lightning/nodes-per-country/nodes-per-country.component.scss b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.scss
index 02b47e8be..25e4cf7f3 100644
--- a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.scss
+++ b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.scss
@@ -13,7 +13,6 @@
width: 30%;
max-width: 400px;
padding-right: 70px;
-
@media (max-width: 576px) {
width: 50%;
max-width: 150px;
@@ -23,7 +22,6 @@
.timestamp-first {
width: 20%;
-
@media (max-width: 576px) {
display: none
}
@@ -31,7 +29,6 @@
.timestamp-update {
width: 16%;
-
@media (max-width: 576px) {
display: none
}
@@ -39,7 +36,6 @@
.capacity {
width: 10%;
-
@media (max-width: 576px) {
width: 25%;
}
@@ -47,7 +43,6 @@
.channels {
width: 10%;
-
@media (max-width: 576px) {
width: 25%;
}
@@ -55,7 +50,6 @@
.city {
max-width: 150px;
-
@media (max-width: 576px) {
display: none
}
diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts
index 0f68b071b..b850e893e 100644
--- a/frontend/src/app/services/api.service.ts
+++ b/frontend/src/app/services/api.service.ts
@@ -262,4 +262,8 @@ export class ApiService {
getNodeForISP$(isp: string): Observable {
return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp/' + isp);
}
+
+ getNodesPerCountry(): Observable {
+ return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries');
+ }
}