diff --git a/backend/src/api/explorer/channels.api.ts b/backend/src/api/explorer/channels.api.ts index a1cd6a41e..f0d7dc56b 100644 --- a/backend/src/api/explorer/channels.api.ts +++ b/backend/src/api/explorer/channels.api.ts @@ -13,6 +13,30 @@ class ChannelsApi { } } + public async $getAllChannelsGeo(): Promise { + try { + const query = `SELECT nodes_1.public_key as node1_public_key, nodes_1.alias AS node1_alias, + nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude, + nodes_2.public_key as node2_public_key, nodes_2.alias AS node2_alias, + nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude, + channels.capacity + FROM channels + JOIN nodes AS nodes_1 on nodes_1.public_key = channels.node1_public_key + JOIN nodes AS nodes_2 on nodes_2.public_key = channels.node2_public_key + WHERE nodes_1.latitude IS NOT NULL AND nodes_1.longitude IS NOT NULL + AND nodes_2.latitude IS NOT NULL AND nodes_2.longitude IS NOT NULL + `; + const [rows]: any = await DB.query(query); + return rows.map((row) => [ + row.node1_public_key, row.node1_alias, row.node1_longitude, row.node1_latitude, + row.node2_public_key, row.node2_alias, row.node2_longitude, row.node2_latitude, + row.capacity]); + } catch (e) { + logger.err('$getAllChannelsGeo error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + public async $searchChannelsById(search: string): Promise { try { const searchStripped = search.replace('%', '') + '%'; diff --git a/backend/src/api/explorer/channels.routes.ts b/backend/src/api/explorer/channels.routes.ts index 5ad1d8743..c6df30802 100644 --- a/backend/src/api/explorer/channels.routes.ts +++ b/backend/src/api/explorer/channels.routes.ts @@ -11,6 +11,7 @@ class ChannelsRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/search/:search', this.$searchChannelsById) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/:short_id', this.$getChannel) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels', this.$getChannelsForNode) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels-geo', this.$getChannelsGeo) ; } @@ -93,6 +94,15 @@ class ChannelsRoutes { } } + private async $getChannelsGeo(req: Request, res: Response) { + try { + const channels = await channelsApi.$getAllChannelsGeo(); + res.json(channels); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + } export default new ChannelsRoutes(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c72038f38..04682aac5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,6 +34,7 @@ "clipboard": "^2.0.10", "domino": "^2.1.6", "echarts": "~5.3.2", + "echarts-gl": "^2.0.9", "express": "^4.17.1", "lightweight-charts": "~3.8.0", "ngx-echarts": "8.0.1", @@ -6396,6 +6397,11 @@ "webpack": ">=4.0.1" } }, + "node_modules/claygl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz", + "integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ==" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -8107,6 +8113,18 @@ "zrender": "5.3.1" } }, + "node_modules/echarts-gl": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz", + "integrity": "sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==", + "dependencies": { + "claygl": "^1.2.1", + "zrender": "^5.1.1" + }, + "peerDependencies": { + "echarts": "^5.1.2" + } + }, "node_modules/echarts/node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", @@ -22520,6 +22538,11 @@ "integrity": "sha512-g38K9Cm5WRwlaH6g03B9OEz/0qRizI+2I7n+Gz+L5DxXJAPAiWQvwlYNm1V1jkdpUv95bOe/ASm2vfi/G560jQ==", "requires": {} }, + "claygl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz", + "integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ==" + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -23866,6 +23889,15 @@ } } }, + "echarts-gl": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz", + "integrity": "sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==", + "requires": { + "claygl": "^1.2.1", + "zrender": "^5.1.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f2d54135e..d2f7f2f6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -88,6 +88,7 @@ "clipboard": "^2.0.10", "domino": "^2.1.6", "echarts": "~5.3.2", + "echarts-gl": "^2.0.9", "express": "^4.17.1", "lightweight-charts": "~3.8.0", "ngx-echarts": "8.0.1", diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index 905b2d296..938d3e817 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -39,7 +39,9 @@ Lightning nodes per country Lightning nodes world map + i18n="lightning.lightning.nodes-heatmap">Lightning nodes world map + Lightning nodes channels world map diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index 1bed752dc..57ef6cef7 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -23,6 +23,7 @@ import { LightningStatisticsChartComponent } from '../lightning/statistics-chart 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'; import { NodesMap } from '../lightning/nodes-map/nodes-map.component'; +import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels-map.component'; const browserWindow = window || {}; // @ts-ignore @@ -114,6 +115,10 @@ const routes: Routes = [ path: 'lightning/nodes-map', component: NodesMap, }, + { + path: 'lightning/nodes-channels-map', + component: NodesChannelsMap, + }, { path: '', redirectTo: 'mempool', diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts index 74cae756c..781418bfd 100644 --- a/frontend/src/app/lightning/lightning.module.ts +++ b/frontend/src/app/lightning/lightning.module.ts @@ -23,6 +23,7 @@ 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'; import { NodesMap } from '../lightning/nodes-map/nodes-map.component'; +import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels-map.component'; @NgModule({ declarations: [ LightningDashboardComponent, @@ -43,6 +44,7 @@ import { NodesMap } from '../lightning/nodes-map/nodes-map.component'; NodesPerISP, NodesPerCountryChartComponent, NodesMap, + NodesChannelsMap, ], imports: [ CommonModule, diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html new file mode 100644 index 000000000..df831608a --- /dev/null +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html @@ -0,0 +1,17 @@ +
+ +
+
+ Lightning nodes channels world map + +
+ (Tor nodes excluded) +
+ +
+
+ +
diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss new file mode 100644 index 000000000..4e363a534 --- /dev/null +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss @@ -0,0 +1,40 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } +} + +.full-container { + padding: 0px 15px; + width: 100%; + min-height: 500px; + height: calc(100% - 150px); + @media (max-width: 992px) { + height: 100%; + padding-bottom: 100px; + }; +} + +.chart { + width: 100%; + height: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts new file mode 100644 index 000000000..19310081f --- /dev/null +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts @@ -0,0 +1,189 @@ +import { ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { SeoService } from 'src/app/services/seo.service'; +import { ApiService } from 'src/app/services/api.service'; +import { Observable, tap, zip } from 'rxjs'; +import { AssetsService } from 'src/app/services/assets.service'; +import { download } 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 { EChartsOption, registerMap } from 'echarts'; +import 'echarts-gl'; +import { SSL_OP_SSLEAY_080_CLIENT_DH_BUG } from 'constants'; + +@Component({ + selector: 'app-nodes-channels-map', + templateUrl: './nodes-channels-map.component.html', + styleUrls: ['./nodes-channels-map.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodesChannelsMap implements OnInit, OnDestroy { + observable$: Observable; + + chartInstance = undefined; + chartOptions: EChartsOption = {color: 'dark'}; + chartInitOptions = { + renderer: 'canvas', + }; + + constructor( + private seoService: SeoService, + private apiService: ApiService, + private stateService: StateService, + private assetsService: AssetsService, + private router: Router, + private zone: NgZone, + ) { + } + + ngOnDestroy(): void {} + + ngOnInit(): void { + this.seoService.setTitle($localize`Lightning nodes channels world map`); + + this.observable$ = zip( + this.assetsService.getWorldMapJson$, + this.apiService.getChannelsGeo$(), + ).pipe(tap((data) => { + registerMap('world', data[0]); + + const channelsLoc = []; + const nodes = []; + for (const channel of data[1]) { + channelsLoc.push([[channel[2], channel[3]], [channel[6], channel[7]]]); + nodes.push({ + publicKey: channel[0], + name: channel[1], + value: [channel[2], channel[3]], + }); + nodes.push({ + publicKey: channel[4], + name: channel[5], + value: [channel[6], channel[7]], + }); + } + + this.prepareChartOptions(nodes, channelsLoc); + })); + } + + prepareChartOptions(nodes, channels) { + let title: object; + if (channels.length === 0) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: $localize`No data to display yet`, + left: 'center', + top: 'center' + }; + } + + this.chartOptions = { + geo3D: { + map: 'world', + shading: 'color', + silent: true, + postEffect: { + enable: true, + bloom: { + intensity: 0.01, + } + }, + viewControl: { + minDistance: 1, + distance: 60, + alpha: 89, + panMouseButton: 'left', + rotateMouseButton: 'right', + zoomSensivity: 0.5, + }, + itemStyle: { + color: '#FFFFFF', + opacity: 0.02, + borderWidth: 1, + borderColor: 'black', + }, + regionHeight: 0.01, + }, + series: [ + { + // @ts-ignore + type: 'lines3D', + coordinateSystem: 'geo3D', + blendMode: 'lighter', + lineStyle: { + width: 1, + opacity: 0.025, + }, + data: channels + }, + { + // @ts-ignore + type: 'scatter3D', + symbol: 'circle', + blendMode: 'lighter', + coordinateSystem: 'geo3D', + symbolSize: 3, + itemStyle: { + color: '#BBFFFF', + opacity: 1, + borderColor: '#FFFFFF00', + }, + data: nodes, + emphasis: { + label: { + position: 'top', + // @ts-ignore + textStyle: { + color: 'white', + fontSize: 16, + }, + formatter: function(value) { + return value.name; + }, + show: true, + } + } + }, + ] + }; + } + + onChartInit(ec) { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + + this.chartInstance.on('click', (e) => { + if (e.data && e.data.publicKey) { + this.zone.run(() => { + const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/node/${e.data.publicKey}`); + 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); + } +} 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 9dd1ef8b5..d2fcb31e0 100644 --- a/frontend/src/app/lightning/nodes-map/nodes-map.component.ts +++ b/frontend/src/app/lightning/nodes-map/nodes-map.component.ts @@ -4,7 +4,7 @@ import { SeoService } from 'src/app/services/seo.service'; import { ApiService } from 'src/app/services/api.service'; import { combineLatest, Observable, tap } from 'rxjs'; import { AssetsService } from 'src/app/services/assets.service'; -import { EChartsOption, MapSeriesOption, registerMap } from 'echarts'; +import { EChartsOption, registerMap } from 'echarts'; import { download } from 'src/app/shared/graphs.utils'; import { Router } from '@angular/router'; import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index efb25ac70..27680e554 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -270,4 +270,8 @@ export class ApiService { getNodesPerCountry(): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries'); } + + getChannelsGeo$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels-geo'); + } }