Add nodes AS share chart and table component
This commit is contained in:
		
							parent
							
								
									2fd34cbd91
								
							
						
					
					
						commit
						28cf0f71eb
					
				@ -100,7 +100,6 @@ class NodesApi {
 | 
			
		||||
        JOIN geo_names ON geo_names.id = nodes.as_number
 | 
			
		||||
        GROUP BY as_number
 | 
			
		||||
        ORDER BY COUNT(*) DESC
 | 
			
		||||
        LIMIT 20
 | 
			
		||||
      `;
 | 
			
		||||
      const [nodesCountPerAS]: any = await DB.query(query);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -34,6 +34,8 @@
 | 
			
		||||
        i18n="lightning.nodes-networks">Nodes per network</a>
 | 
			
		||||
      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
 | 
			
		||||
        i18n="lightning.capacity">Network capacity</a>
 | 
			
		||||
      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-as' | relativeUrl]"
 | 
			
		||||
        i18n="lightning.nodes-per-as">Nodes per AS</a>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@ -20,6 +20,7 @@ import { TelevisionComponent } from '../components/television/television.compone
 | 
			
		||||
import { DashboardComponent } from '../dashboard/dashboard.component';
 | 
			
		||||
import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
 | 
			
		||||
import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component';
 | 
			
		||||
import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.component';
 | 
			
		||||
 | 
			
		||||
const browserWindow = window || {};
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
@ -99,6 +100,10 @@ const routes: Routes = [
 | 
			
		||||
            path: 'lightning/capacity',
 | 
			
		||||
            component: LightningStatisticsChartComponent,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            path: 'lightning/nodes-per-as',
 | 
			
		||||
            component: NodesPerAsChartComponent,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            path: '',
 | 
			
		||||
            redirectTo: 'mempool',
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ import { NodeStatisticsChartComponent } from './node-statistics-chart/node-stati
 | 
			
		||||
import { GraphsModule } from '../graphs/graphs.module';
 | 
			
		||||
import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
 | 
			
		||||
import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
 | 
			
		||||
import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.component';
 | 
			
		||||
@NgModule({
 | 
			
		||||
  declarations: [
 | 
			
		||||
    LightningDashboardComponent,
 | 
			
		||||
@ -33,6 +34,7 @@ import { ChannelsStatisticsComponent } from './channels-statistics/channels-stat
 | 
			
		||||
    LightningStatisticsChartComponent,
 | 
			
		||||
    NodesNetworksChartComponent,
 | 
			
		||||
    ChannelsStatisticsComponent,
 | 
			
		||||
    NodesPerAsChartComponent,
 | 
			
		||||
  ],
 | 
			
		||||
  imports: [
 | 
			
		||||
    CommonModule,
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,41 @@
 | 
			
		||||
<div class="full-container h-100">
 | 
			
		||||
 | 
			
		||||
  <div class="card-header">
 | 
			
		||||
    <span i18n="lightning.nodes-per-as">Nodes per AS</span>
 | 
			
		||||
    <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
			
		||||
      <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="container pb-lg-0 bottom-padding">
 | 
			
		||||
    <div class="pb-lg-5" *ngIf="nodesPerAsObservable$ | async">
 | 
			
		||||
      <div class="chart w-100" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
 | 
			
		||||
        (chartInit)="onChartInit($event)">
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="text-center loadingGraphs" *ngIf="isLoading">
 | 
			
		||||
      <div class="spinner-border text-light"></div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <table class="table table-borderless text-center">
 | 
			
		||||
      <thead>
 | 
			
		||||
        <tr>
 | 
			
		||||
          <th class="d-none d-md-block" i18n="mining.rank">Rank</th>
 | 
			
		||||
          <th class="" i18n="lightning.as-name">Name</th>
 | 
			
		||||
          <th class="" i18n="lightning.share">Hashrate</th>
 | 
			
		||||
          <th class="" i18n="lightning.nodes">Nodes</th>
 | 
			
		||||
        </tr>
 | 
			
		||||
      </thead>
 | 
			
		||||
      <tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerAsObservable$ | async) as asList">
 | 
			
		||||
        <tr *ngFor="let asEntry of asList">
 | 
			
		||||
          <td class="d-none d-md-block">{{ asEntry.rank }}</td>
 | 
			
		||||
          <td class="text-truncate" style="max-width: 100px">{{ asEntry.name }}</td>
 | 
			
		||||
          <td class="">{{ asEntry.share }}%</td>
 | 
			
		||||
          <td class="">{{ asEntry.count }}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      </tbody>
 | 
			
		||||
    </table>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
@ -0,0 +1,36 @@
 | 
			
		||||
.card-header {
 | 
			
		||||
  border-bottom: 0;
 | 
			
		||||
  font-size: 18px;
 | 
			
		||||
  @media (min-width: 465px) {
 | 
			
		||||
    font-size: 20px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full-container {
 | 
			
		||||
  padding: 0px 15px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: calc(100% - 140px);
 | 
			
		||||
  @media (max-width: 992px) {
 | 
			
		||||
    height: calc(100% - 190px);
 | 
			
		||||
  };
 | 
			
		||||
  @media (max-width: 575px) {
 | 
			
		||||
    height: calc(100% - 230px);
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.chart {
 | 
			
		||||
  max-height: 400px;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    max-height: 230px;
 | 
			
		||||
    margin-top: -35px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.bottom-padding {
 | 
			
		||||
  @media (max-width: 992px) {
 | 
			
		||||
    padding-bottom: 65px
 | 
			
		||||
  };
 | 
			
		||||
  @media (max-width: 576px) {
 | 
			
		||||
    padding-bottom: 65px
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,210 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, OnInit, HostBinding } from '@angular/core';
 | 
			
		||||
import { EChartsOption, PieSeriesOption } from 'echarts';
 | 
			
		||||
import { map, Observable, share, tap } from 'rxjs';
 | 
			
		||||
import { chartColors } from 'src/app/app.constants';
 | 
			
		||||
import { ApiService } from 'src/app/services/api.service';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { download } from 'src/app/shared/graphs.utils';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-nodes-per-as-chart',
 | 
			
		||||
  templateUrl: './nodes-per-as-chart.component.html',
 | 
			
		||||
  styleUrls: ['./nodes-per-as-chart.component.scss'],
 | 
			
		||||
  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
			
		||||
})
 | 
			
		||||
export class NodesPerAsChartComponent implements OnInit {
 | 
			
		||||
  miningWindowPreference: string;
 | 
			
		||||
 | 
			
		||||
  isLoading = true;
 | 
			
		||||
  chartOptions: EChartsOption = {};
 | 
			
		||||
  chartInitOptions = {
 | 
			
		||||
    renderer: 'svg',
 | 
			
		||||
  };
 | 
			
		||||
  timespan = '';
 | 
			
		||||
  chartInstance: any = undefined;
 | 
			
		||||
 | 
			
		||||
  @HostBinding('attr.dir') dir = 'ltr';
 | 
			
		||||
 | 
			
		||||
  nodesPerAsObservable$: Observable<any>;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private apiService: ApiService,
 | 
			
		||||
    private seoService: SeoService,
 | 
			
		||||
  ) {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.seoService.setTitle($localize`Nodes per AS`);
 | 
			
		||||
 | 
			
		||||
    this.nodesPerAsObservable$ = this.apiService.getNodesPerAs()
 | 
			
		||||
      .pipe(
 | 
			
		||||
        tap(data => {
 | 
			
		||||
          this.isLoading = false;
 | 
			
		||||
          this.prepareChartOptions(data);
 | 
			
		||||
        }),
 | 
			
		||||
        map(data => {
 | 
			
		||||
          for (let i = 0; i < data.length; ++i) {
 | 
			
		||||
            data[i].rank = i + 1;
 | 
			
		||||
          }
 | 
			
		||||
          return data.slice(0, 100);
 | 
			
		||||
        }),
 | 
			
		||||
        share()
 | 
			
		||||
      );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generateChartSerieData(as) {
 | 
			
		||||
    const shareThreshold = this.isMobile() ? 2 : 1;
 | 
			
		||||
    const data: object[] = [];
 | 
			
		||||
    let totalShareOther = 0;
 | 
			
		||||
    let totalNodeOther = 0;
 | 
			
		||||
 | 
			
		||||
    let edgeDistance: string | number = '10%';
 | 
			
		||||
    if (this.isMobile()) {
 | 
			
		||||
      edgeDistance = 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    as.forEach((as) => {
 | 
			
		||||
      if (as.share < shareThreshold) {
 | 
			
		||||
        totalShareOther += as.share;
 | 
			
		||||
        totalNodeOther += as.count;
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      data.push({
 | 
			
		||||
        value: as.share,
 | 
			
		||||
        name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`),
 | 
			
		||||
        label: {
 | 
			
		||||
          overflow: 'truncate',
 | 
			
		||||
          color: '#b1b1b1',
 | 
			
		||||
          alignTo: 'edge',
 | 
			
		||||
          edgeDistance: edgeDistance,
 | 
			
		||||
        },
 | 
			
		||||
        tooltip: {
 | 
			
		||||
          show: !this.isMobile(),
 | 
			
		||||
          backgroundColor: 'rgba(17, 19, 31, 1)',
 | 
			
		||||
          borderRadius: 4,
 | 
			
		||||
          shadowColor: 'rgba(0, 0, 0, 0.5)',
 | 
			
		||||
          textStyle: {
 | 
			
		||||
            color: '#b1b1b1',
 | 
			
		||||
          },
 | 
			
		||||
          borderColor: '#000',
 | 
			
		||||
          formatter: () => {
 | 
			
		||||
            return `<b style="color: white">${as.name} (${as.share}%)</b><br>` +
 | 
			
		||||
              $localize`${as.count.toString()} nodes`;
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        data: as.slug,
 | 
			
		||||
      } as PieSeriesOption);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // 'Other'
 | 
			
		||||
    data.push({
 | 
			
		||||
      itemStyle: {
 | 
			
		||||
        color: 'grey',
 | 
			
		||||
      },
 | 
			
		||||
      value: totalShareOther,
 | 
			
		||||
      name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`),
 | 
			
		||||
      label: {
 | 
			
		||||
        overflow: 'truncate',
 | 
			
		||||
        color: '#b1b1b1',
 | 
			
		||||
        alignTo: 'edge',
 | 
			
		||||
        edgeDistance: edgeDistance
 | 
			
		||||
      },
 | 
			
		||||
      tooltip: {
 | 
			
		||||
        backgroundColor: 'rgba(17, 19, 31, 1)',
 | 
			
		||||
        borderRadius: 4,
 | 
			
		||||
        shadowColor: 'rgba(0, 0, 0, 0.5)',
 | 
			
		||||
        textStyle: {
 | 
			
		||||
          color: '#b1b1b1',
 | 
			
		||||
        },
 | 
			
		||||
        borderColor: '#000',
 | 
			
		||||
        formatter: () => {
 | 
			
		||||
          return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` +
 | 
			
		||||
            totalNodeOther.toString() + ` nodes`;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
    } as PieSeriesOption);
 | 
			
		||||
 | 
			
		||||
    return data;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  prepareChartOptions(as) {
 | 
			
		||||
    let pieSize = ['20%', '80%']; // Desktop
 | 
			
		||||
    if (this.isMobile()) {
 | 
			
		||||
      pieSize = ['15%', '60%'];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.chartOptions = {
 | 
			
		||||
      color: chartColors,
 | 
			
		||||
      tooltip: {
 | 
			
		||||
        trigger: 'item',
 | 
			
		||||
        textStyle: {
 | 
			
		||||
          align: 'left',
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      series: [
 | 
			
		||||
        {
 | 
			
		||||
          zlevel: 0,
 | 
			
		||||
          minShowLabelAngle: 3.6,
 | 
			
		||||
          name: 'Mining pool',
 | 
			
		||||
          type: 'pie',
 | 
			
		||||
          radius: pieSize,
 | 
			
		||||
          data: this.generateChartSerieData(as),
 | 
			
		||||
          labelLine: {
 | 
			
		||||
            lineStyle: {
 | 
			
		||||
              width: 2,
 | 
			
		||||
            },
 | 
			
		||||
            length: this.isMobile() ? 1 : 20,
 | 
			
		||||
            length2: this.isMobile() ? 1 : undefined,
 | 
			
		||||
          },
 | 
			
		||||
          label: {
 | 
			
		||||
            fontSize: 14,
 | 
			
		||||
          },
 | 
			
		||||
          itemStyle: {
 | 
			
		||||
            borderRadius: 1,
 | 
			
		||||
            borderWidth: 1,
 | 
			
		||||
            borderColor: '#000',
 | 
			
		||||
          },
 | 
			
		||||
          emphasis: {
 | 
			
		||||
            itemStyle: {
 | 
			
		||||
              shadowBlur: 40,
 | 
			
		||||
              shadowColor: 'rgba(0, 0, 0, 0.75)',
 | 
			
		||||
            },
 | 
			
		||||
            labelLine: {
 | 
			
		||||
              lineStyle: {
 | 
			
		||||
                width: 4,
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isMobile() {
 | 
			
		||||
    return (window.innerWidth <= 767.98);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChartInit(ec) {
 | 
			
		||||
    if (this.chartInstance !== undefined) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    this.chartInstance = ec;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onSaveChart() {
 | 
			
		||||
    const now = new Date();
 | 
			
		||||
    this.chartOptions.backgroundColor = '#11131f';
 | 
			
		||||
    this.chartInstance.setOption(this.chartOptions);
 | 
			
		||||
    download(this.chartInstance.getDataURL({
 | 
			
		||||
      pixelRatio: 2,
 | 
			
		||||
      excludeComponents: ['dataZoom'],
 | 
			
		||||
    }), `ln-nodes-per-as-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`);
 | 
			
		||||
    this.chartOptions.backgroundColor = 'none';
 | 
			
		||||
    this.chartInstance.setOption(this.chartOptions);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isEllipsisActive(e) {
 | 
			
		||||
    return (e.offsetWidth < e.scrollWidth);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -251,4 +251,7 @@ export class ApiService {
 | 
			
		||||
    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getNodesPerAs(): Observable<any> {
 | 
			
		||||
    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/asShare');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user