Merge pull request #2542 from mononaut/isp-preview
Add Lightning ISP preview
This commit is contained in:
		
						commit
						33334fd94c
					
				| @ -8,10 +8,12 @@ import { LightningApiService } from './lightning-api.service'; | ||||
| import { NodePreviewComponent } from './node/node-preview.component'; | ||||
| import { LightningPreviewsRoutingModule } from './lightning-previews.routing.module'; | ||||
| import { ChannelPreviewComponent } from './channel/channel-preview.component'; | ||||
| import { NodesPerISPPreview } from './nodes-per-isp/nodes-per-isp-preview.component'; | ||||
| @NgModule({ | ||||
|   declarations: [ | ||||
|     NodePreviewComponent, | ||||
|     ChannelPreviewComponent, | ||||
|     NodesPerISPPreview, | ||||
|   ], | ||||
|   imports: [ | ||||
|     CommonModule, | ||||
|  | ||||
| @ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; | ||||
| import { RouterModule, Routes } from '@angular/router'; | ||||
| import { NodePreviewComponent } from './node/node-preview.component'; | ||||
| import { ChannelPreviewComponent } from './channel/channel-preview.component'; | ||||
| import { NodesPerISPPreview } from './nodes-per-isp/nodes-per-isp-preview.component'; | ||||
| 
 | ||||
| const routes: Routes = [ | ||||
|     { | ||||
| @ -12,6 +13,10 @@ const routes: Routes = [ | ||||
|       path: 'channel/:short_id', | ||||
|       component: ChannelPreviewComponent, | ||||
|     }, | ||||
|     { | ||||
|       path: 'nodes/isp/:isp', | ||||
|       component: NodesPerISPPreview, | ||||
|     }, | ||||
|     { | ||||
|       path: '**', | ||||
|       redirectTo: '' | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| <div class="full-container" [class]="widget ? 'widget' : ''"> | ||||
| <div class="full-container" [class]="widget ? 'widget' : ''" [class.fit-container]="fitContainer"> | ||||
| 
 | ||||
|   <div *ngIf="!widget" class="card-header"> | ||||
|     <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px"> | ||||
| @ -8,7 +8,7 @@ | ||||
|   </div> | ||||
| 
 | ||||
|   <div *ngIf="observable$ | async" class="chart" [class]="widget ? 'widget' : ''" echarts [initOpts]="chartInitOptions" [options]="chartOptions" | ||||
|     (chartInit)="onChartInit($event)"> | ||||
|     (chartInit)="onChartInit($event)" (chartFinished)="onChartFinished($event)"> | ||||
|   </div> | ||||
| 
 | ||||
| </div> | ||||
|  | ||||
| @ -21,6 +21,17 @@ | ||||
|   height: 240px; | ||||
|   padding: 0px; | ||||
| } | ||||
| .full-container.fit-container { | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   height: 100%; | ||||
|   min-height: 100px; | ||||
| 
 | ||||
|   .chart { | ||||
|     padding: 0; | ||||
|     min-height: 100px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .chart { | ||||
|   width: 100%; | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, NgZone, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { ChangeDetectionStrategy, Component, Inject, Input, Output, EventEmitter, LOCALE_ID, NgZone, OnDestroy, OnInit, OnChanges } 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 { Observable, BehaviorSubject, switchMap, tap, combineLatest } from 'rxjs'; | ||||
| import { AssetsService } from 'src/app/services/assets.service'; | ||||
| import { EChartsOption, registerMap } from 'echarts'; | ||||
| import { lerpColor } from 'src/app/shared/graphs.utils'; | ||||
| @ -17,11 +17,14 @@ import { getFlagEmoji } from 'src/app/shared/common.utils'; | ||||
|   styleUrls: ['./nodes-map.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class NodesMap implements OnInit { | ||||
| export class NodesMap implements OnInit, OnChanges { | ||||
|   @Input() widget: boolean = false; | ||||
|   @Input() nodes: any[] | undefined = undefined; | ||||
|   @Input() type: 'none' | 'isp' | 'country' = 'none'; | ||||
|    | ||||
|   @Input() fitContainer = false; | ||||
|   @Output() readyEvent = new EventEmitter(); | ||||
|   inputNodes$: BehaviorSubject<any>; | ||||
|   nodes$: Observable<any>; | ||||
|   observable$: Observable<any>; | ||||
| 
 | ||||
|   chartInstance = undefined; | ||||
| @ -45,9 +48,17 @@ export class NodesMap implements OnInit { | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle($localize`Lightning nodes world map`); | ||||
| 
 | ||||
|     this.observable$ = zip( | ||||
|     if (!this.inputNodes$) { | ||||
|       this.inputNodes$ = new BehaviorSubject(this.nodes); | ||||
|     } | ||||
| 
 | ||||
|     this.nodes$ = this.inputNodes$.pipe( | ||||
|       switchMap((nodes) =>  nodes ? [nodes] : this.apiService.getWorldNodes$()) | ||||
|     ); | ||||
| 
 | ||||
|     this.observable$ = combineLatest( | ||||
|       this.assetsService.getWorldMapJson$, | ||||
|       this.nodes ? [this.nodes] : this.apiService.getWorldNodes$() | ||||
|       this.nodes$ | ||||
|     ).pipe(tap((data) => { | ||||
|       registerMap('world', data[0]); | ||||
| 
 | ||||
| @ -110,6 +121,16 @@ export class NodesMap implements OnInit { | ||||
|     })); | ||||
|   } | ||||
| 
 | ||||
|   ngOnChanges(changes): void { | ||||
|     if (changes.nodes) { | ||||
|       if (!this.inputNodes$) { | ||||
|         this.inputNodes$ = new BehaviorSubject(changes.nodes.currentValue); | ||||
|       } else { | ||||
|         this.inputNodes$.next(changes.nodes.currentValue); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   prepareChartOptions(nodes, maxLiquidity, mapCenter, mapZoom) { | ||||
|     let title: object; | ||||
|     if (nodes.length === 0) { | ||||
| @ -224,4 +245,8 @@ export class NodesMap implements OnInit { | ||||
|       this.chartInstance.resize(); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   onChartFinished(e) { | ||||
|     this.readyEvent.emit(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,63 @@ | ||||
| <div class="box preview-box" *ngIf="(nodes$ | async) as ispNodes"> | ||||
|   <app-preview-title> | ||||
|     <span i18n="lightning.node">lightning ISP</span> | ||||
|   </app-preview-title> | ||||
|   <div class="row d-flex justify-content-between full-width-row"> | ||||
|     <div class="title-wrapper"> | ||||
|       <h1 class="title">{{ isp?.name }}</h1> | ||||
|       <a class="subtitle" [routerLink]="['/lightning/nodes/isp/' | relativeUrl, isp?.id]"> | ||||
|         ASN {{ isp?.id }} | ||||
|       </a> | ||||
|     </div> | ||||
|     <div class="logo-wrapper"> | ||||
| 
 | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="row"> | ||||
|     <div class="col-md"> | ||||
|       <table class="table table-borderless table-striped table-fixed"> | ||||
|         <col span="1" width="250px"> | ||||
|         <tbody> | ||||
|           <tr> | ||||
|             <td i18n="lightning.node-count">Nodes</td> | ||||
|             <td>{{ ispNodes.nodes.length }}</td> | ||||
|           </tr> | ||||
|           <tr> | ||||
|             <td i18n="lightning.liquidity">Liquidity</td> | ||||
|             <td> | ||||
|               <app-amount *ngIf="ispNodes.sumLiquidity > 100000000; else smallnode" [satoshis]="ispNodes.sumLiquidity" [digitsInfo]="'1.2-2'" [noFiat]="false"></app-amount> | ||||
|               <ng-template #smallnode> | ||||
|                 <app-sats [satoshis]="ispNodes.sumLiquidity" digitsInfo="1.0-2"></app-sats> | ||||
|               </ng-template> | ||||
|             </td> | ||||
|           </tr> | ||||
|           <tr> | ||||
|             <td i18n="lightning.channels">Channels</td> | ||||
|             <td>{{ ispNodes.sumChannels }}</td> | ||||
|           </tr> | ||||
|           <tr> | ||||
|             <td i18n="lightning.top-country">Top country</td> | ||||
|             <td class="text-truncate"> | ||||
|               <span class="">{{ ispNodes.topCountry.country }} {{ ispNodes.topCountry.flag }}</span> | ||||
|             </td> | ||||
|           </tr> | ||||
|           <tr> | ||||
|             <td i18n="lightning.top-node">Top node</td> | ||||
|             <td class="text-truncate"> | ||||
|               {{ ispNodes.nodes[0].alias }} | ||||
|             </td> | ||||
|           </tr> | ||||
|         </tbody> | ||||
|       </table> | ||||
|     </div> | ||||
|     <div class="col-md map-col"> | ||||
|       <app-nodes-map [widget]="true" [nodes]="ispNodes.nodes" type="isp" [fitContainer]="true" (readyEvent)="onMapReady()"></app-nodes-map> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| 
 | ||||
| <ng-template [ngIf]="error"> | ||||
|   <div class="text-center"> | ||||
|     <span i18n="error.general-loading-data">Error loading data.</span> | ||||
|   </div> | ||||
| </ng-template> | ||||
| @ -0,0 +1,31 @@ | ||||
| .table { | ||||
|   font-size: 32px; | ||||
|   margin-top: 0px; | ||||
| } | ||||
| 
 | ||||
| .map-col { | ||||
|   flex-grow: 0; | ||||
|   flex-shrink: 0; | ||||
|   width: 470px; | ||||
|   height: 360px; | ||||
|   min-width: 470px; | ||||
|   min-height: 360px; | ||||
|   max-height: 360px; | ||||
|   padding: 0; | ||||
|   background: #181b2d; | ||||
|   overflow: hidden; | ||||
|   margin-top: 0; | ||||
| } | ||||
| 
 | ||||
| .row { | ||||
|   margin-right: 0; | ||||
| } | ||||
| 
 | ||||
| .full-width-row { | ||||
|   padding-left: 15px; | ||||
|   flex-wrap: nowrap; | ||||
| } | ||||
| 
 | ||||
| ::ng-deep .symbol { | ||||
|   font-size: 24px; | ||||
| } | ||||
| @ -0,0 +1,103 @@ | ||||
| import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | ||||
| import { ActivatedRoute, ParamMap } from '@angular/router'; | ||||
| import { catchError, map, switchMap, Observable, share, of } from 'rxjs'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { SeoService } from 'src/app/services/seo.service'; | ||||
| import { OpenGraphService } from 'src/app/services/opengraph.service'; | ||||
| import { getFlagEmoji } from 'src/app/shared/common.utils'; | ||||
| import { GeolocationData } from 'src/app/shared/components/geolocation/geolocation.component'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-nodes-per-isp-preview', | ||||
|   templateUrl: './nodes-per-isp-preview.component.html', | ||||
|   styleUrls: ['./nodes-per-isp-preview.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class NodesPerISPPreview implements OnInit { | ||||
|   nodes$: Observable<any>; | ||||
|   isp: {name: string, id: number}; | ||||
|   id: string; | ||||
|   error: Error; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private seoService: SeoService, | ||||
|     private openGraphService: OpenGraphService, | ||||
|     private route: ActivatedRoute, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.nodes$ = this.route.paramMap | ||||
|       .pipe( | ||||
|         switchMap((params: ParamMap) => { | ||||
|           this.id = params.get('isp'); | ||||
|           this.isp = null; | ||||
|           this.openGraphService.waitFor('isp-map-' + this.id); | ||||
|           this.openGraphService.waitFor('isp-data-' + this.id); | ||||
|           return this.apiService.getNodeForISP$(params.get('isp')); | ||||
|         }), | ||||
|         map(response => { | ||||
|           this.isp = { | ||||
|             name: response.isp, | ||||
|             id: this.route.snapshot.params.isp.split(',').join(', ') | ||||
|           }; | ||||
|           this.seoService.setTitle($localize`Lightning nodes on ISP: ${response.isp} [AS${this.route.snapshot.params.isp}]`); | ||||
| 
 | ||||
|           for (const i in response.nodes) { | ||||
|             response.nodes[i].geolocation = <GeolocationData>{ | ||||
|               country: response.nodes[i].country?.en, | ||||
|               city: response.nodes[i].city?.en, | ||||
|               subdivision: response.nodes[i].subdivision?.en, | ||||
|               iso: response.nodes[i].iso_code, | ||||
|             }; | ||||
|           } | ||||
| 
 | ||||
|           const sumLiquidity = response.nodes.reduce((partialSum, a) => partialSum + a.capacity, 0); | ||||
|           const sumChannels = response.nodes.reduce((partialSum, a) => partialSum + a.channels, 0); | ||||
|           const countries = {}; | ||||
|           const topCountry = { | ||||
|             count: 0, | ||||
|             country: '', | ||||
|             iso: '', | ||||
|             flag: '', | ||||
|           }; | ||||
|           for (const node of response.nodes) { | ||||
|             if (!node.geolocation.iso) { | ||||
|               continue; | ||||
|             } | ||||
|             countries[node.geolocation.iso] = countries[node.geolocation.iso] ?? 0 + 1; | ||||
|             if (countries[node.geolocation.iso] > topCountry.count) { | ||||
|               topCountry.count = countries[node.geolocation.iso]; | ||||
|               topCountry.country = node.geolocation.country; | ||||
|               topCountry.iso = node.geolocation.iso; | ||||
|             } | ||||
|           } | ||||
|           topCountry.flag = getFlagEmoji(topCountry.iso); | ||||
| 
 | ||||
|           this.openGraphService.waitOver('isp-data-' + this.id); | ||||
| 
 | ||||
|           return { | ||||
|             nodes: response.nodes, | ||||
|             sumLiquidity: sumLiquidity, | ||||
|             sumChannels: sumChannels, | ||||
|             topCountry: topCountry, | ||||
|           }; | ||||
|         }), | ||||
|         catchError(err => { | ||||
|           this.error = err; | ||||
|           this.openGraphService.fail('isp-map-' + this.id); | ||||
|           this.openGraphService.fail('isp-data-' + this.id); | ||||
|           return of({ | ||||
|             nodes: [], | ||||
|             sumLiquidity: 0, | ||||
|             sumChannels: 0, | ||||
|             topCountry: {}, | ||||
|           }); | ||||
|         }) | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   onMapReady() { | ||||
|     this.openGraphService.waitOver('isp-map-' + this.id); | ||||
|   } | ||||
| } | ||||
| @ -46,6 +46,17 @@ const routes = { | ||||
|           return `Lightning Channel: ${path[0]}`; | ||||
|         } | ||||
|       }, | ||||
|       nodes: { | ||||
|         routes: { | ||||
|           isp: { | ||||
|             render: true, | ||||
|             params: 1, | ||||
|             getTitle(path) { | ||||
|               return `Lightning ISP: ${path[0]}`; | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   mining: { | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user