mirror of
https://github.com/mempool/mempool.git
synced 2024-11-20 02:11:49 +01:00
Merge pull request #2118 from mempool/nymkappa/feature/ln-per-country-chart
LN nodes per country pie chart
This commit is contained in:
commit
36412bedd1
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
||||
<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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
})
|
||||
);
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user