mirror of
https://github.com/mempool/mempool.git
synced 2025-02-21 14:04:15 +01:00
Show clearnet nodes on world map
This commit is contained in:
parent
9047cb5998
commit
367c06dca6
7 changed files with 167 additions and 90 deletions
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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' +
|
||||||
|
|
Loading…
Add table
Reference in a new issue