Merge pull request #2139 from mempool/nymkappa/feature/ln-nodes-map
Create lightning nodes world heat map (clearnet)
This commit is contained in:
commit
c7909a1ca8
@ -85,7 +85,7 @@ if (configContent && configContent.BASE_MODULE == "liquid") {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
PROXY_CONFIG.push({
|
PROXY_CONFIG.push({
|
||||||
context: ['/resources/pools.json', '/resources/assets.json', '/resources/assets.minimal.json'],
|
context: ['/resources/pools.json', '/resources/assets.json', '/resources/assets.minimal.json', '/resources/worldmap.json'],
|
||||||
target: "https://mempool.space",
|
target: "https://mempool.space",
|
||||||
secure: false,
|
secure: false,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
@ -38,6 +38,8 @@
|
|||||||
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]"
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-country' | relativeUrl]"
|
||||||
i18n="lightning.nodes-per-isp">Lightning nodes per country</a>
|
i18n="lightning.nodes-per-isp">Lightning nodes per country</a>
|
||||||
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-map' | relativeUrl]"
|
||||||
|
i18n="lightning.nodes-per-isp">Lightning nodes world map</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,6 +22,7 @@ import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/n
|
|||||||
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';
|
import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
|
||||||
|
import { NodesMap } from '../lightning/nodes-map/nodes-map.component';
|
||||||
|
|
||||||
const browserWindow = window || {};
|
const browserWindow = window || {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -109,6 +110,10 @@ const routes: Routes = [
|
|||||||
path: 'lightning/nodes-per-country',
|
path: 'lightning/nodes-per-country',
|
||||||
component: NodesPerCountryChartComponent,
|
component: NodesPerCountryChartComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lightning/nodes-map',
|
||||||
|
component: NodesMap,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirectTo: 'mempool',
|
redirectTo: 'mempool',
|
||||||
|
@ -22,6 +22,7 @@ import { NodesPerISPChartComponent } from './nodes-per-isp-chart/nodes-per-isp-c
|
|||||||
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';
|
import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
|
||||||
|
import { NodesMap } from '../lightning/nodes-map/nodes-map.component';
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
LightningDashboardComponent,
|
LightningDashboardComponent,
|
||||||
@ -41,6 +42,7 @@ import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-ch
|
|||||||
NodesPerCountry,
|
NodesPerCountry,
|
||||||
NodesPerISP,
|
NodesPerISP,
|
||||||
NodesPerCountryChartComponent,
|
NodesPerCountryChartComponent,
|
||||||
|
NodesMap,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
<div class="full-container">
|
||||||
|
|
||||||
|
<div class="card-header">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="observable$ | async" class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||||
|
(chartInit)="onChartInit($event)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
163
frontend/src/app/lightning/nodes-map/nodes-map.component.ts
Normal file
163
frontend/src/app/lightning/nodes-map/nodes-map.component.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { mempoolFeeColors } from 'src/app/app.constants';
|
||||||
|
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 { 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';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nodes-map',
|
||||||
|
templateUrl: './nodes-map.component.html',
|
||||||
|
styleUrls: ['./nodes-map.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NodesMap implements OnInit, OnDestroy {
|
||||||
|
observable$: Observable<any>;
|
||||||
|
|
||||||
|
chartInstance = undefined;
|
||||||
|
chartOptions: EChartsOption = {};
|
||||||
|
chartInitOptions = {
|
||||||
|
renderer: 'svg',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 world map`);
|
||||||
|
|
||||||
|
this.observable$ = combineLatest([
|
||||||
|
this.assetsService.getWorldMapJson$,
|
||||||
|
this.apiService.getNodesPerCountry()
|
||||||
|
]).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);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.prepareChartOptions(countries, max);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareChartOptions(countries, max) {
|
||||||
|
let title: object;
|
||||||
|
if (countries.length === 0) {
|
||||||
|
title = {
|
||||||
|
textStyle: {
|
||||||
|
color: 'grey',
|
||||||
|
fontSize: 15
|
||||||
|
},
|
||||||
|
text: $localize`No data to display yet`,
|
||||||
|
left: 'center',
|
||||||
|
top: 'center'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
borderColor: '#000',
|
||||||
|
formatter: function(country) {
|
||||||
|
if (country.data === undefined) {
|
||||||
|
return `<b style="color: white">${country.name}<br>0 nodes</b><br>`;
|
||||||
|
} else {
|
||||||
|
return `<b style="color: white">${country.data.name}<br>${country.data.value} nodes</b><br>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
areaColor: '#FDD835',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: countries,
|
||||||
|
itemStyle: {
|
||||||
|
areaColor: '#5A6A6D'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onChartInit(ec) {
|
||||||
|
if (this.chartInstance !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chartInstance = ec;
|
||||||
|
|
||||||
|
this.chartInstance.on('click', (e) => {
|
||||||
|
if (e.data && e.data.value > 0) {
|
||||||
|
this.zone.run(() => {
|
||||||
|
const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/country/${e.data.iso}`);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
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';
|
||||||
|
@ -14,6 +14,7 @@ export class AssetsService {
|
|||||||
|
|
||||||
getAssetsJson$: Observable<{ array: AssetExtended[]; objects: any}>;
|
getAssetsJson$: Observable<{ array: AssetExtended[]; objects: any}>;
|
||||||
getAssetsMinimalJson$: Observable<any>;
|
getAssetsMinimalJson$: Observable<any>;
|
||||||
|
getWorldMapJson$: Observable<any>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private httpClient: HttpClient,
|
private httpClient: HttpClient,
|
||||||
@ -65,5 +66,7 @@ export class AssetsService {
|
|||||||
}),
|
}),
|
||||||
shareReplay(1),
|
shareReplay(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.getWorldMapJson$ = this.httpClient.get(apiBaseUrl + '/resources/worldmap.json').pipe(shareReplay());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
frontend/src/resources/worldmap.json
Normal file
1
frontend/src/resources/worldmap.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user