Merge branch 'master' into nymkappa/bugfix/nodes-per-as-css

This commit is contained in:
wiz 2022-07-17 18:30:43 -05:00 committed by GitHub
commit cc8c52e848
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 483 additions and 25 deletions

View File

@ -129,7 +129,7 @@ class NodesApi {
public async $getNodesPerCountry(countryId: string) { public async $getNodesPerCountry(countryId: string) {
try { try {
const query = ` const query = `
SELECT DISTINCT node_stats.public_key, node_stats.capacity, node_stats.channels, nodes.alias, SELECT node_stats.public_key, node_stats.capacity, node_stats.channels, nodes.alias,
UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at,
geo_names_city.names as city geo_names_city.names as city
FROM node_stats FROM node_stats
@ -139,8 +139,8 @@ class NodesApi {
GROUP BY public_key GROUP BY public_key
) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added
JOIN nodes ON nodes.public_key = node_stats.public_key JOIN nodes ON nodes.public_key = node_stats.public_key
JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
WHERE geo_names_country.id = ? WHERE geo_names_country.id = ?
ORDER BY capacity DESC ORDER BY capacity DESC
`; `;
@ -186,6 +186,39 @@ class NodesApi {
throw e; 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(); 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/search/:search', this.$searchNode)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes) .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/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/statistics', this.$getHistoricalNodeStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode) .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); 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(); export default new NodesRoutes();

View File

@ -62,7 +62,7 @@ class KrakenApi implements PriceFeed {
// CHF weekly price history goes back to timestamp 1575504000 (December 5, 2019) // CHF weekly price history goes back to timestamp 1575504000 (December 5, 2019)
// AUD weekly price history goes back to timestamp 1591833600 (June 11, 2020) // AUD weekly price history goes back to timestamp 1591833600 (June 11, 2020)
const priceHistory: any = {}; // map: timestamp -> Prices let priceHistory: any = {}; // map: timestamp -> Prices
for (const currency of this.currencies) { for (const currency of this.currencies) {
const response = await query(this.urlHist.replace('{GRANULARITY}', '10080') + currency); const response = await query(this.urlHist.replace('{GRANULARITY}', '10080') + currency);
@ -83,6 +83,10 @@ class KrakenApi implements PriceFeed {
} }
for (const time in priceHistory) { for (const time in priceHistory) {
if (priceHistory[time].USD === -1) {
delete priceHistory[time];
continue;
}
await PricesRepository.$savePrices(parseInt(time, 10), priceHistory[time]); await PricesRepository.$savePrices(parseInt(time, 10), priceHistory[time]);
} }

View File

@ -31,11 +31,13 @@
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button> <button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1"> <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]"
i18n="lightning.nodes-networks">Nodes per network</a> i18n="lightning.nodes-networks">Lightning nodes per network</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]" <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
i18n="lightning.capacity">Network capacity</a> i18n="lightning.capacity">Network capacity</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]" <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]"
i18n="lightning.nodes-per-isp">Lightning nodes per ISP</a> 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> </div>
</div> </div>

View File

@ -109,7 +109,7 @@ export class HashrateChartComponent implements OnInit {
while (hashIndex < data.hashrates.length) { while (hashIndex < data.hashrates.length) {
diffFixed.push({ diffFixed.push({
timestamp: data.hashrates[hashIndex].timestamp, timestamp: data.hashrates[hashIndex].timestamp,
difficulty: data.difficulty[data.difficulty.length - 1].difficulty difficulty: data.difficulty.length > 0 ? data.difficulty[data.difficulty.length - 1].difficulty : null
}); });
++hashIndex; ++hashIndex;
} }
@ -231,11 +231,15 @@ export class HashrateChartComponent implements OnInit {
} else if (tick.seriesIndex === 1) { // Difficulty } else if (tick.seriesIndex === 1) { // Difficulty
let difficultyPowerOfTen = hashratePowerOfTen; let difficultyPowerOfTen = hashratePowerOfTen;
let difficulty = tick.data[1]; let difficulty = tick.data[1];
if (this.isMobile()) { if (difficulty === null) {
difficultyPowerOfTen = selectPowerOfTen(tick.data[1]); difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`;
difficulty = Math.round(tick.data[1] / difficultyPowerOfTen.divider); } else {
if (this.isMobile()) {
difficultyPowerOfTen = selectPowerOfTen(tick.data[1]);
difficulty = Math.round(tick.data[1] / difficultyPowerOfTen.divider);
}
difficultyString = `${tick.marker} ${tick.seriesName}: ${formatNumber(difficulty, this.locale, '1.2-2')} ${difficultyPowerOfTen.unit}<br>`;
} }
difficultyString = `${tick.marker} ${tick.seriesName}: ${formatNumber(difficulty, this.locale, '1.2-2')} ${difficultyPowerOfTen.unit}<br>`;
} else if (tick.seriesIndex === 2) { // Hashrate MA } else if (tick.seriesIndex === 2) { // Hashrate MA
let hashrate = tick.data[1]; let hashrate = tick.data[1];
if (this.isMobile()) { if (this.isMobile()) {

View File

@ -44,9 +44,6 @@
<li class="nav-item" routerLinkActive="active" id="btn-graphs"> <li class="nav-item" routerLinkActive="active" id="btn-graphs">
<a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a> <a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a>
</li> </li>
<li class="nav-item d-none d-lg-block" routerLinkActive="active" id="btn-tv">
<a class="nav-link" [routerLink]="['/tv' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon></a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-docs"> <li class="nav-item" routerLinkActive="active" id="btn-docs">
<a class="nav-link" [routerLink]="['/docs' | relativeUrl ]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="documentation.title" title="Documentation"></fa-icon></a> <a class="nav-link" [routerLink]="['/docs' | relativeUrl ]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="documentation.title" title="Documentation"></fa-icon></a>
</li> </li>

View File

@ -12,6 +12,13 @@
<form [formGroup]="radioGroupForm" class="formRadioGroup" <form [formGroup]="radioGroupForm" class="formRadioGroup"
[class]="stateService.env.MINING_DASHBOARD ? 'mining' : ''" (click)="saveGraphPreference()"> [class]="stateService.env.MINING_DASHBOARD ? 'mining' : ''" (click)="saveGraphPreference()">
<div *ngIf="!isMobile()" class="btn-group btn-group-toggle">
<label ngbButtonLabel class="btn-primary btn-sm mr-2">
<a [routerLink]="['/tv' | relativeUrl]" style="color: white">
<fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon>
</a>
</label>
</div>
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
<label ngbButtonLabel class="btn-primary btn-sm"> <label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h"> 2H <input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h"> 2H

View File

@ -210,4 +210,8 @@ export class StatisticsComponent implements OnInit {
this.incomingGraph.onSaveChart(this.timespan); this.incomingGraph.onSaveChart(this.timespan);
} }
} }
isMobile() {
return (window.innerWidth <= 767.98);
}
} }

View File

@ -21,6 +21,7 @@ import { DashboardComponent } from '../dashboard/dashboard.component';
import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component'; import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-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 { 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 || {}; const browserWindow = window || {};
// @ts-ignore // @ts-ignore
@ -104,6 +105,10 @@ const routes: Routes = [
path: 'lightning/nodes-per-isp', path: 'lightning/nodes-per-isp',
component: NodesPerISPChartComponent, component: NodesPerISPChartComponent,
}, },
{
path: 'lightning/nodes-per-country',
component: NodesPerCountryChartComponent,
},
{ {
path: '', path: '',
redirectTo: 'mempool', 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 { NodesPerISPChartComponent } from './nodes-per-isp-chart/nodes-per-isp-chart.component';
import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component'; import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.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({ @NgModule({
declarations: [ declarations: [
LightningDashboardComponent, LightningDashboardComponent,
@ -39,6 +40,7 @@ import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
NodesPerISPChartComponent, NodesPerISPChartComponent,
NodesPerCountry, NodesPerCountry,
NodesPerISP, NodesPerISP,
NodesPerCountryChartComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@ -2,7 +2,7 @@
<div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''"> <div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
<div class="d-flex d-md-block align-items-baseline"> <div class="d-flex d-md-block align-items-baseline">
<span i18n="mining.nodes-networks">Nodes count by network</span> <span i18n="lightning.nodes-networks">Lightning nodes per network</span>
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
</button> </button>

View File

@ -61,7 +61,7 @@ export class NodesNetworksChartComponent implements OnInit {
if (this.widget) { if (this.widget) {
this.miningWindowPreference = '1y'; this.miningWindowPreference = '1y';
} else { } else {
this.seoService.setTitle($localize`Nodes per network`); this.seoService.setTitle($localize`Lightning nodes per network`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('all'); this.miningWindowPreference = this.miningService.getDefaultTimespan('all');
} }
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });

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"> <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"> <div style="min-height: 295px">
<table class="table table-borderless"> <table class="table table-borderless">

View File

@ -13,7 +13,6 @@
width: 30%; width: 30%;
max-width: 400px; max-width: 400px;
padding-right: 70px; padding-right: 70px;
@media (max-width: 576px) { @media (max-width: 576px) {
width: 50%; width: 50%;
max-width: 150px; max-width: 150px;
@ -23,7 +22,6 @@
.timestamp-first { .timestamp-first {
width: 20%; width: 20%;
@media (max-width: 576px) { @media (max-width: 576px) {
display: none display: none
} }
@ -31,7 +29,6 @@
.timestamp-update { .timestamp-update {
width: 16%; width: 16%;
@media (max-width: 576px) { @media (max-width: 576px) {
display: none display: none
} }
@ -39,7 +36,6 @@
.capacity { .capacity {
width: 10%; width: 10%;
@media (max-width: 576px) { @media (max-width: 576px) {
width: 25%; width: 25%;
} }
@ -47,7 +43,6 @@
.channels { .channels {
width: 10%; width: 10%;
@media (max-width: 576px) { @media (max-width: 576px) {
width: 25%; width: 25%;
} }
@ -55,7 +50,6 @@
.city { .city {
max-width: 150px; max-width: 150px;
@media (max-width: 576px) { @media (max-width: 576px) {
display: none display: none
} }

View File

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

View File

@ -262,4 +262,8 @@ export class ApiService {
getNodeForISP$(isp: string): Observable<any> { getNodeForISP$(isp: string): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp/' + isp); 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'); const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
return (gl && gl instanceof WebGLRenderingContext); return (gl && gl instanceof WebGLRenderingContext);
} }
export function getFlagEmoji(countryCode) {
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt());
return String.fromCodePoint(...codePoints);
}