diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 128405ffd..b959d49a0 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -5,6 +5,49 @@ import { ILightningApi } from '../lightning/lightning-api.interface'; import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces'; class NodesApi { + public async $getWorldNodes(): Promise { + 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 { try { // General info diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index 7a5ff880a..cf3f75208 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -9,6 +9,7 @@ class NodesRoutes { public initRoutes(app: Application) { 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/search/:search', this.$searchNode) .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 { try { const nodesPerAs = await nodesApi.$getNodesISPRanking(); - res.header('Pragma', 'public'); res.header('Cache-control', 'public'); 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) { try { const [country]: any[] = await DB.query( diff --git a/frontend/src/app/components/asset-circulation/asset-circulation.component.ts b/frontend/src/app/components/asset-circulation/asset-circulation.component.ts index 4d65417e6..d64607fe1 100644 --- a/frontend/src/app/components/asset-circulation/asset-circulation.component.ts +++ b/frontend/src/app/components/asset-circulation/asset-circulation.component.ts @@ -4,7 +4,6 @@ import { map } from 'rxjs/operators'; import { moveDec } from 'src/app/bitcoin.utils'; import { AssetsService } from 'src/app/services/assets.service'; import { ElectrsApiService } from 'src/app/services/electrs-api.service'; -import { formatNumber } from '@angular/common'; import { environment } from 'src/environments/environment'; @Component({ diff --git a/frontend/src/app/lightning/nodes-map/nodes-map.component.html b/frontend/src/app/lightning/nodes-map/nodes-map.component.html index b762b2d24..75f8aeb08 100644 --- a/frontend/src/app/lightning/nodes-map/nodes-map.component.html +++ b/frontend/src/app/lightning/nodes-map/nodes-map.component.html @@ -2,10 +2,7 @@
- Lightning nodes world heat map - + Lightning nodes world map
(Tor nodes excluded)
diff --git a/frontend/src/app/lightning/nodes-map/nodes-map.component.ts b/frontend/src/app/lightning/nodes-map/nodes-map.component.ts index d2fcb31e0..6c809916e 100644 --- a/frontend/src/app/lightning/nodes-map/nodes-map.component.ts +++ b/frontend/src/app/lightning/nodes-map/nodes-map.component.ts @@ -1,14 +1,15 @@ -import { ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit } from '@angular/core'; -import { mempoolFeeColors } from 'src/app/app.constants'; +import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit } from '@angular/core'; import { SeoService } from 'src/app/services/seo.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 { 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 { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; 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({ selector: 'app-nodes-map', @@ -16,7 +17,7 @@ import { StateService } from 'src/app/services/state.service'; styleUrls: ['./nodes-map.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class NodesMap implements OnInit, OnDestroy { +export class NodesMap implements OnInit { observable$: Observable; chartInstance = undefined; @@ -26,44 +27,52 @@ export class NodesMap implements OnInit, OnDestroy { }; constructor( + @Inject(LOCALE_ID) public locale: string, private seoService: SeoService, private apiService: ApiService, private stateService: StateService, private assetsService: AssetsService, private router: Router, private zone: NgZone, + private amountShortenerPipe: AmountShortenerPipe ) { } - ngOnDestroy(): void {} - ngOnInit(): void { this.seoService.setTitle($localize`Lightning nodes world map`); - this.observable$ = combineLatest([ + this.observable$ = zip( this.assetsService.getWorldMapJson$, - this.apiService.getNodesPerCountry() - ]).pipe(tap((data) => { + this.apiService.getWorldNodes$() + ).pipe(tap((data) => { registerMap('world', data[0]); - const countries = []; - let max = 0; - for (const country of data[1]) { - countries.push({ - name: country.name.en, - value: country.count, - iso: country.iso.toLowerCase(), - }); - max = Math.max(max, country.count); + const nodes: any[] = []; + console.log(data[1].nodes[0]); + for (const node of data[1].nodes) { + // We add a bit of noise so nodes at the same location are not all + // on top of each other + const random = Math.random() * 2 * Math.PI; + const random2 = Math.random() * 0.01; + nodes.push([ + 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; - if (countries.length === 0) { + if (nodes.length === 0) { title = { textStyle: { color: 'grey', @@ -76,53 +85,80 @@ export class NodesMap implements OnInit, OnDestroy { } this.chartOptions = { - title: countries.length === 0 ? title : undefined, - tooltip: { - backgroundColor: 'rgba(17, 19, 31, 1)', - borderRadius: 4, - shadowColor: 'rgba(0, 0, 0, 0.5)', - textStyle: { - color: '#b1b1b1', + silent: false, + title: title ?? undefined, + tooltip: {}, + geo: { + animation: false, + silent: true, + center: [0, 5], + zoom: 1.3, + tooltip: { + show: false }, - borderColor: '#000', - formatter: function(country) { - if (country.data === undefined) { - return `${country.name}
0 nodes

`; - } else { - return `${country.data.name}
${country.data.value} nodes

`; - } + map: 'world', + roam: true, + itemStyle: { + borderColor: 'black', + color: '#272b3f' + }, + scaleLimit: { + min: 1.3, + max: 100000, + }, + emphasis: { + disabled: true, } }, - visualMap: { - left: 'right', - show: true, - min: 1, - max: max, - text: ['High', 'Low'], - calculable: true, - textStyle: { - color: 'white', - }, - inRange: { - color: mempoolFeeColors.map(color => `#${color}`), - }, - }, - series: { - type: 'map', - map: 'world', - emphasis: { - label: { - show: false, + series: [ + { + large: false, + type: 'scatter', + data: nodes, + coordinateSystem: 'geo', + geoIndex: 0, + progressive: 500, + symbolSize: function (params) { + return 10 * Math.pow(params[2] / maxLiquidity, 0.2) + 3; + }, + tooltip: { + trigger: 'item', + show: true, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + 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 ` + ${alias}
+ ${liquidity}
+ ${data[5]} channels
+ ${getFlagEmoji(data[7])} ${data[6]} + `; + } }, 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.on('click', (e) => { - if (e.data && e.data.value > 0) { + if (e.data) { 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]); }); } }); - } - onSaveChart() { - // @ts-ignore - 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); + this.chartInstance.on('georoam', (e) => { + this.chartInstance.resize(); + }); } } diff --git a/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts index 09b00e032..9f1b3fe88 100644 --- a/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts +++ b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts @@ -45,7 +45,7 @@ export class NodesPerCountryChartComponent implements OnInit { ngOnInit(): void { this.seoService.setTitle($localize`Lightning nodes per country`); - this.nodesPerCountryObservable$ = this.apiService.getNodesPerCountry() + this.nodesPerCountryObservable$ = this.apiService.getNodesPerCountry$() .pipe( map(data => { for (let i = 0; i < data.length; ++i) { diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 5f036c575..8c0f5ecd0 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -267,10 +267,14 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp/' + isp); } - getNodesPerCountry(): Observable { + getNodesPerCountry$(): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries'); } + getWorldNodes$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/world'); + } + getChannelsGeo$(publicKey?: string, style?: 'graph' | 'nodepage' | 'widget' | 'channelpage'): Observable { return this.httpClient.get( this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels-geo' +