Show clearnet nodes on world map

This commit is contained in:
nymkappa 2022-09-06 19:33:07 +02:00
parent 9047cb5998
commit 367c06dca6
No known key found for this signature in database
GPG key ID: E155910B16E8BD04
7 changed files with 167 additions and 90 deletions

View file

@ -5,6 +5,49 @@ import { ILightningApi } from '../lightning/lightning-api.interface';
import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces'; import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces';
class NodesApi { class NodesApi {
public async $getWorldNodes(): Promise<any> {
try {
let query = `
SELECT nodes.public_key as publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
CAST(COALESCE(nodes.capacity, 0) as INT) as capacity,
CAST(COALESCE(nodes.channels, 0) as INT) as channels,
nodes.longitude, nodes.latitude,
geo_names_country.names as country, geo_names_iso.names as isoCode
FROM nodes
LEFT 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_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
WHERE status = 1 AND nodes.as_number IS NOT NULL
ORDER BY capacity
`;
const [nodes]: any[] = await DB.query(query);
for (let i = 0; i < nodes.length; ++i) {
nodes[i].country = JSON.parse(nodes[i].country);
}
query = `
SELECT MAX(nodes.capacity) as maxLiquidity, MAX(nodes.channels) as maxChannels
FROM nodes
WHERE status = 1 AND nodes.as_number IS NOT NULL
`;
const [maximums]: any[] = await DB.query(query);
return {
maxLiquidity: maximums[0].maxLiquidity,
maxChannels: maximums[0].maxChannels,
nodes: nodes.map(node => [
node.longitude, node.latitude,
node.publicKey, node.alias, node.capacity, node.channels,
node.country, node.isoCode
])
};
} catch (e) {
logger.err(`Can't get world nodes list. Reason: ${e instanceof Error ? e.message : e}`);
}
}
public async $getNode(public_key: string): Promise<any> { public async $getNode(public_key: string): Promise<any> {
try { try {
// General info // General info

View file

@ -9,6 +9,7 @@ class NodesRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
app app
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/world', this.$getWorldNodes)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry)
.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/isp-ranking', this.$getISPRanking) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp-ranking', this.$getISPRanking)
@ -115,7 +116,6 @@ class NodesRoutes {
private async $getISPRanking(req: Request, res: Response): Promise<void> { private async $getISPRanking(req: Request, res: Response): Promise<void> {
try { try {
const nodesPerAs = await nodesApi.$getNodesISPRanking(); const nodesPerAs = await nodesApi.$getNodesISPRanking();
res.header('Pragma', 'public'); res.header('Pragma', 'public');
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
@ -125,6 +125,18 @@ class NodesRoutes {
} }
} }
private async $getWorldNodes(req: Request, res: Response) {
try {
const worldNodes = await nodesApi.$getWorldNodes();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
res.json(worldNodes);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getNodesPerCountry(req: Request, res: Response) { private async $getNodesPerCountry(req: Request, res: Response) {
try { try {
const [country]: any[] = await DB.query( const [country]: any[] = await DB.query(

View file

@ -4,7 +4,6 @@ import { map } from 'rxjs/operators';
import { moveDec } from 'src/app/bitcoin.utils'; import { moveDec } from 'src/app/bitcoin.utils';
import { AssetsService } from 'src/app/services/assets.service'; import { AssetsService } from 'src/app/services/assets.service';
import { ElectrsApiService } from 'src/app/services/electrs-api.service'; import { ElectrsApiService } from 'src/app/services/electrs-api.service';
import { formatNumber } from '@angular/common';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
@Component({ @Component({

View file

@ -2,10 +2,7 @@
<div class="card-header"> <div class="card-header">
<div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px"> <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
<span i18n="lightning.nodes-heatmap">Lightning nodes world heat map</span> <span i18n="lightning.nodes-world-map">Lightning nodes world map</span>
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px">
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true" (click)="onSaveChart()"></fa-icon>
</button>
</div> </div>
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small> <small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
</div> </div>

View file

@ -1,14 +1,15 @@
import { ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit } from '@angular/core';
import { mempoolFeeColors } from 'src/app/app.constants';
import { SeoService } from 'src/app/services/seo.service'; import { SeoService } from 'src/app/services/seo.service';
import { ApiService } from 'src/app/services/api.service'; import { ApiService } from 'src/app/services/api.service';
import { combineLatest, Observable, tap } from 'rxjs'; import { Observable, tap, zip } from 'rxjs';
import { AssetsService } from 'src/app/services/assets.service'; import { AssetsService } from 'src/app/services/assets.service';
import { EChartsOption, registerMap } from 'echarts'; import { EChartsOption, registerMap } from 'echarts';
import { download } from 'src/app/shared/graphs.utils'; import { lerpColor } from 'src/app/shared/graphs.utils';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
import { StateService } from 'src/app/services/state.service'; import { StateService } from 'src/app/services/state.service';
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
import { getFlagEmoji } from 'src/app/shared/common.utils';
@Component({ @Component({
selector: 'app-nodes-map', selector: 'app-nodes-map',
@ -16,7 +17,7 @@ import { StateService } from 'src/app/services/state.service';
styleUrls: ['./nodes-map.component.scss'], styleUrls: ['./nodes-map.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class NodesMap implements OnInit, OnDestroy { export class NodesMap implements OnInit {
observable$: Observable<any>; observable$: Observable<any>;
chartInstance = undefined; chartInstance = undefined;
@ -26,44 +27,52 @@ export class NodesMap implements OnInit, OnDestroy {
}; };
constructor( constructor(
@Inject(LOCALE_ID) public locale: string,
private seoService: SeoService, private seoService: SeoService,
private apiService: ApiService, private apiService: ApiService,
private stateService: StateService, private stateService: StateService,
private assetsService: AssetsService, private assetsService: AssetsService,
private router: Router, private router: Router,
private zone: NgZone, private zone: NgZone,
private amountShortenerPipe: AmountShortenerPipe
) { ) {
} }
ngOnDestroy(): void {}
ngOnInit(): void { ngOnInit(): void {
this.seoService.setTitle($localize`Lightning nodes world map`); this.seoService.setTitle($localize`Lightning nodes world map`);
this.observable$ = combineLatest([ this.observable$ = zip(
this.assetsService.getWorldMapJson$, this.assetsService.getWorldMapJson$,
this.apiService.getNodesPerCountry() this.apiService.getWorldNodes$()
]).pipe(tap((data) => { ).pipe(tap((data) => {
registerMap('world', data[0]); registerMap('world', data[0]);
const countries = []; const nodes: any[] = [];
let max = 0; console.log(data[1].nodes[0]);
for (const country of data[1]) { for (const node of data[1].nodes) {
countries.push({ // We add a bit of noise so nodes at the same location are not all
name: country.name.en, // on top of each other
value: country.count, const random = Math.random() * 2 * Math.PI;
iso: country.iso.toLowerCase(), const random2 = Math.random() * 0.01;
}); nodes.push([
max = Math.max(max, country.count); node[0] + random2 * Math.cos(random),
node[1] + random2 * Math.sin(random),
node[4], // Liquidity
node[3], // Alias
node[2], // Public key
node[5], // Channels
node[6].en, // Country
node[7], // ISO Code
]);
} }
this.prepareChartOptions(countries, max); this.prepareChartOptions(nodes, data[1].maxLiquidity);
})); }));
} }
prepareChartOptions(countries, max) { prepareChartOptions(nodes, maxLiquidity) {
let title: object; let title: object;
if (countries.length === 0) { if (nodes.length === 0) {
title = { title = {
textStyle: { textStyle: {
color: 'grey', color: 'grey',
@ -76,53 +85,80 @@ export class NodesMap implements OnInit, OnDestroy {
} }
this.chartOptions = { this.chartOptions = {
title: countries.length === 0 ? title : undefined, silent: false,
tooltip: { title: title ?? undefined,
backgroundColor: 'rgba(17, 19, 31, 1)', tooltip: {},
borderRadius: 4, geo: {
shadowColor: 'rgba(0, 0, 0, 0.5)', animation: false,
textStyle: { silent: true,
color: '#b1b1b1', center: [0, 5],
zoom: 1.3,
tooltip: {
show: false
}, },
borderColor: '#000', map: 'world',
formatter: function(country) { roam: true,
if (country.data === undefined) { itemStyle: {
return `<b style="color: white">${country.name}<br>0 nodes</b><br>`; borderColor: 'black',
} else { color: '#272b3f'
return `<b style="color: white">${country.data.name}<br>${country.data.value} nodes</b><br>`; },
} scaleLimit: {
min: 1.3,
max: 100000,
},
emphasis: {
disabled: true,
} }
}, },
visualMap: { series: [
left: 'right', {
show: true, large: false,
min: 1, type: 'scatter',
max: max, data: nodes,
text: ['High', 'Low'], coordinateSystem: 'geo',
calculable: true, geoIndex: 0,
textStyle: { progressive: 500,
color: 'white', symbolSize: function (params) {
}, return 10 * Math.pow(params[2] / maxLiquidity, 0.2) + 3;
inRange: { },
color: mempoolFeeColors.map(color => `#${color}`), tooltip: {
}, trigger: 'item',
}, show: true,
series: { backgroundColor: 'rgba(17, 19, 31, 1)',
type: 'map', borderRadius: 4,
map: 'world', shadowColor: 'rgba(0, 0, 0, 0.5)',
emphasis: { textStyle: {
label: { color: '#b1b1b1',
show: false, align: 'left',
},
borderColor: '#000',
formatter: (value) => {
const data = value.data;
const alias = data[3].length > 0 ? data[3] : data[4].slice(0, 20);
const liquidity = data[2] >= 100000000 ?
`${this.amountShortenerPipe.transform(data[2] / 100000000)} BTC` :
`${this.amountShortenerPipe.transform(data[2], 2)} sats`;
return `
<b style="color: white">${alias}</b><br>
${liquidity}<br>
${data[5]} channels<br>
${getFlagEmoji(data[7])} ${data[6]}
`;
}
}, },
itemStyle: { itemStyle: {
areaColor: '#FDD835', color: function (params) {
} return `${lerpColor('#1E88E5', '#D81B60', Math.pow(params.data[2] / maxLiquidity, 0.2))}`;
},
opacity: 1,
borderColor: 'black',
borderWidth: 0,
},
blendMode: 'lighter',
zlevel: 2,
}, },
data: countries, ]
itemStyle: {
areaColor: '#5A6A6D'
},
}
}; };
} }
@ -134,30 +170,16 @@ export class NodesMap implements OnInit, OnDestroy {
this.chartInstance = ec; this.chartInstance = ec;
this.chartInstance.on('click', (e) => { this.chartInstance.on('click', (e) => {
if (e.data && e.data.value > 0) { if (e.data) {
this.zone.run(() => { this.zone.run(() => {
const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/country/${e.data.iso}`); const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/node/${e.data[4]}`);
this.router.navigate([url]); this.router.navigate([url]);
}); });
} }
}); });
}
onSaveChart() { this.chartInstance.on('georoam', (e) => {
// @ts-ignore this.chartInstance.resize();
const prevBottom = this.chartOptions.grid.bottom; });
const now = new Date();
// @ts-ignore
this.chartOptions.grid.bottom = 30;
this.chartOptions.backgroundColor = '#11131f';
this.chartInstance.setOption(this.chartOptions);
download(this.chartInstance.getDataURL({
pixelRatio: 2,
excludeComponents: ['dataZoom'],
}), `lightning-nodes-heatmap-clearnet-${Math.round(now.getTime() / 1000)}.svg`);
// @ts-ignore
this.chartOptions.grid.bottom = prevBottom;
this.chartOptions.backgroundColor = 'none';
this.chartInstance.setOption(this.chartOptions);
} }
} }

View file

@ -45,7 +45,7 @@ export class NodesPerCountryChartComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.seoService.setTitle($localize`Lightning nodes per country`); this.seoService.setTitle($localize`Lightning nodes per country`);
this.nodesPerCountryObservable$ = this.apiService.getNodesPerCountry() this.nodesPerCountryObservable$ = this.apiService.getNodesPerCountry$()
.pipe( .pipe(
map(data => { map(data => {
for (let i = 0; i < data.length; ++i) { for (let i = 0; i < data.length; ++i) {

View file

@ -267,10 +267,14 @@ export class ApiService {
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> { getNodesPerCountry$(): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries'); return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries');
} }
getWorldNodes$(): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/world');
}
getChannelsGeo$(publicKey?: string, style?: 'graph' | 'nodepage' | 'widget' | 'channelpage'): Observable<any> { getChannelsGeo$(publicKey?: string, style?: 'graph' | 'nodepage' | 'widget' | 'channelpage'): Observable<any> {
return this.httpClient.get<any[]>( return this.httpClient.get<any[]>(
this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels-geo' + this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels-geo' +