Merge pull request #2118 from mempool/nymkappa/feature/ln-per-country-chart

LN nodes per country pie chart
This commit is contained in:
wiz 2022-07-17 18:06:07 -05:00 committed by GitHub
commit 36412bedd1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 452 additions and 10 deletions

View File

@ -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();

View File

@ -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();

View File

@ -36,6 +36,8 @@
i18n="lightning.capacity">Network capacity</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]"
i18n="lightning.nodes-per-isp">Lightning nodes per ISP</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-country' | relativeUrl]"
i18n="lightning.nodes-per-isp">Lightning nodes per country</a>
</div>
</div>
</div>

View File

@ -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',

View File

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

View File

@ -0,0 +1,58 @@
<div class="full-container h-100">
<div class="card-header">
<div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
<span i18n="lightning.nodes-per-country">Lightning nodes per country</span>
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
</button>
</div>
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
</div>
<div class="container pb-lg-0 bottom-padding">
<div class="pb-lg-5">
<div class="chart w-100" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)">
</div>
</div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
<table class="table table-borderless text-center m-auto" style="max-width: 900px">
<thead>
<tr>
<th class="text-left rank" i18n="mining.rank">Rank</th>
<th class="text-left name" i18n="lightning.as-name">Name</th>
<th class="text-right share" i18n="lightning.share">Share</th>
<th class="text-right nodes" i18n="lightning.nodes-count">Nodes</th>
<th class="text-right capacity" i18n="lightning.capacity">Capacity</th>
</tr>
</thead>
<tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerCountryObservable$ | async) as countries">
<tr *ngFor="let country of countries">
<td class="text-left rank">{{ country.rank }}</td>
<td class="text-left text-truncate name">
<div class="d-flex">
<span style="font-size: 20px">{{ country.flag }}</span>
&nbsp;
<a class="mt-auto mb-auto" [routerLink]="['/lightning/nodes/country' | relativeUrl, country.iso]">{{ country.name.en }}</a>
</div>
</td>
<td class="text-right share">{{ country.share }}%</td>
<td class="text-right nodes">{{ country.count }}</td>
<td class="text-right capacity">
<app-amount *ngIf="country.capacity > 100000000; else smallchannel" [satoshis]="country.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
<ng-template #smallchannel>
{{ country.capacity | amountShortener: 1 }}
<span class="sats" i18n="shared.sats">sats</span>
</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -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;
}
}

View File

@ -0,0 +1,235 @@
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';
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
@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<any>;
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(
map(data => {
for (let i = 0; i < data.length; ++i) {
data[i].rank = i + 1;
data[i].iso = data[i].iso.toLowerCase();
data[i].flag = getFlagEmoji(data[i].iso);
}
return data.slice(0, 100);
}),
tap(data => {
this.isLoading = false;
this.prepareChartOptions(data);
}),
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 `<b style="color: white">${country.name.en} (${country.share}%)</b><br>` +
$localize`${country.count.toString()} nodes<br>` +
$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 `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` +
totalNodeOther.toString() + ` nodes`;
},
},
data: 9999 as any
} 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);
}
}

View File

@ -1,5 +1,8 @@
<div class="container-xl full-height" style="min-height: 335px">
<h1 class="float-left" i18n="lightning.nodes-in-country">Lightning nodes in {{ country }}</h1>
<h1 class="float-left" i18n="lightning.nodes-in-country">
<span>Lightning nodes in {{ country?.name }}</span>
<span style="font-size: 50px; vertical-align:sub;"> {{ country?.flag }}</span>
</h1>
<div style="min-height: 295px">
<table class="table table-borderless">

View File

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

View File

@ -3,6 +3,7 @@ import { ActivatedRoute } from '@angular/router';
import { map, Observable } from 'rxjs';
import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.service';
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
@Component({
selector: 'app-nodes-per-country',
@ -12,7 +13,7 @@ import { SeoService } from 'src/app/services/seo.service';
})
export class NodesPerCountry implements OnInit {
nodes$: Observable<any>;
country: string;
country: {name: string, flag: string};
constructor(
private apiService: ApiService,
@ -24,8 +25,11 @@ export class NodesPerCountry implements OnInit {
this.nodes$ = this.apiService.getNodeForCountry$(this.route.snapshot.params.country)
.pipe(
map(response => {
this.country = response.country.en
this.seoService.setTitle($localize`Lightning nodes in ${this.country}`);
this.country = {
name: response.country.en,
flag: getFlagEmoji(this.route.snapshot.params.country)
};
this.seoService.setTitle($localize`Lightning nodes in ${this.country.name}`);
return response.nodes;
})
);

View File

@ -262,4 +262,8 @@ export class ApiService {
getNodeForISP$(isp: string): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp/' + isp);
}
getNodesPerCountry(): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries');
}
}

View File

@ -90,3 +90,11 @@ export function detectWebGL() {
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
return (gl && gl instanceof WebGLRenderingContext);
}
export function getFlagEmoji(countryCode) {
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt());
return String.fromCodePoint(...codePoints);
}