Merge branch 'master' into nymkappa/feature/stop-updating-closed-channels
This commit is contained in:
		
						commit
						7c25fd61da
					
				@ -207,6 +207,10 @@ export class Common {
 | 
			
		||||
 | 
			
		||||
  /** Decodes a channel id returned by lnd as uint64 to a short channel id */
 | 
			
		||||
  static channelIntegerIdToShortId(id: string): string {
 | 
			
		||||
    if (id.indexOf('/') !== -1) {
 | 
			
		||||
      id = id.slice(0, -2);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (id.indexOf('x') !== -1) { // Already a short id
 | 
			
		||||
      return id;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ import logger from '../logger';
 | 
			
		||||
import { Common } from './common';
 | 
			
		||||
 | 
			
		||||
class DatabaseMigration {
 | 
			
		||||
  private static currentVersion = 37;
 | 
			
		||||
  private static currentVersion = 38;
 | 
			
		||||
  private queryTimeout = 120000;
 | 
			
		||||
  private statisticsAddedIndexed = false;
 | 
			
		||||
  private uniqueLogs: string[] = [];
 | 
			
		||||
@ -248,7 +248,6 @@ class DatabaseMigration {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (databaseSchemaVersion < 25 && isBitcoin === true) {
 | 
			
		||||
      await this.$executeQuery(`INSERT INTO state VALUES('last_node_stats', 0, '1970-01-01');`);
 | 
			
		||||
      await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats'));
 | 
			
		||||
      await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes'));
 | 
			
		||||
      await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels'));
 | 
			
		||||
@ -328,6 +327,16 @@ class DatabaseMigration {
 | 
			
		||||
    if (databaseSchemaVersion < 37 && isBitcoin == true) {
 | 
			
		||||
      await this.$executeQuery(this.getCreateLNNodesSocketsTableQuery(), await this.$checkIfTableExists('nodes_sockets'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (databaseSchemaVersion < 38 && isBitcoin == true) {
 | 
			
		||||
      if (config.LIGHTNING.ENABLED) {
 | 
			
		||||
        this.uniqueLog(logger.notice, `'lightning_stats' and 'node_stats' tables have been truncated.`);
 | 
			
		||||
      }
 | 
			
		||||
      await this.$executeQuery(`TRUNCATE lightning_stats`);
 | 
			
		||||
      await this.$executeQuery(`TRUNCATE node_stats`);
 | 
			
		||||
      await this.$executeQuery('ALTER TABLE `lightning_stats` CHANGE `added` `added` timestamp NULL');
 | 
			
		||||
      await this.$executeQuery('ALTER TABLE `node_stats` CHANGE `added` `added` timestamp NULL');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 | 
			
		||||
@ -61,9 +61,14 @@ class ChannelsApi {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async $getChannelsByStatus(status: number): Promise<any[]> {
 | 
			
		||||
  public async $getChannelsByStatus(status: number | number[]): Promise<any[]> {
 | 
			
		||||
    try {
 | 
			
		||||
      const query = `SELECT * FROM channels WHERE status = ?`;
 | 
			
		||||
      let query: string;
 | 
			
		||||
      if (Array.isArray(status)) {
 | 
			
		||||
        query = `SELECT * FROM channels WHERE status IN (${status.join(',')})`;
 | 
			
		||||
      } else {
 | 
			
		||||
        query = `SELECT * FROM channels WHERE status = ?`;
 | 
			
		||||
      }
 | 
			
		||||
      const [rows]: any = await DB.query(query, [status]);
 | 
			
		||||
      return rows;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
@ -218,23 +223,25 @@ class ChannelsApi {
 | 
			
		||||
 | 
			
		||||
      // Channels originating from node
 | 
			
		||||
      let query = `
 | 
			
		||||
        SELECT node2.alias, node2.public_key, channels.status, channels.node1_fee_rate,
 | 
			
		||||
        SELECT COALESCE(node2.alias, SUBSTRING(node2_public_key, 0, 20)) AS alias, COALESCE(node2.public_key, node2_public_key) AS public_key,
 | 
			
		||||
          channels.status, channels.node1_fee_rate,
 | 
			
		||||
          channels.capacity, channels.short_id, channels.id
 | 
			
		||||
        FROM channels
 | 
			
		||||
        JOIN nodes AS node2 ON node2.public_key = channels.node2_public_key
 | 
			
		||||
        LEFT JOIN nodes AS node2 ON node2.public_key = channels.node2_public_key
 | 
			
		||||
        WHERE node1_public_key = ? AND channels.status ${channelStatusFilter}
 | 
			
		||||
      `;
 | 
			
		||||
      const [channelsFromNode]: any = await DB.query(query, [public_key, index, length]);
 | 
			
		||||
      const [channelsFromNode]: any = await DB.query(query, [public_key]);
 | 
			
		||||
 | 
			
		||||
      // Channels incoming to node
 | 
			
		||||
      query = `
 | 
			
		||||
        SELECT node1.alias, node1.public_key, channels.status, channels.node2_fee_rate,
 | 
			
		||||
        SELECT COALESCE(node1.alias, SUBSTRING(node1_public_key, 0, 20)) AS alias, COALESCE(node1.public_key, node1_public_key) AS public_key,
 | 
			
		||||
          channels.status, channels.node2_fee_rate,
 | 
			
		||||
          channels.capacity, channels.short_id, channels.id
 | 
			
		||||
        FROM channels
 | 
			
		||||
        JOIN nodes AS node1 ON node1.public_key = channels.node1_public_key
 | 
			
		||||
        LEFT JOIN nodes AS node1 ON node1.public_key = channels.node1_public_key
 | 
			
		||||
        WHERE node2_public_key = ? AND channels.status ${channelStatusFilter}
 | 
			
		||||
      `;
 | 
			
		||||
      const [channelsToNode]: any = await DB.query(query, [public_key, index, length]);
 | 
			
		||||
      const [channelsToNode]: any = await DB.query(query, [public_key]);
 | 
			
		||||
 | 
			
		||||
      let allChannels = channelsFromNode.concat(channelsToNode);
 | 
			
		||||
      allChannels.sort((a, b) => {
 | 
			
		||||
@ -337,7 +344,7 @@ class ChannelsApi {
 | 
			
		||||
  /**
 | 
			
		||||
   * Save or update a channel present in the graph
 | 
			
		||||
   */
 | 
			
		||||
  public async $saveChannel(channel: ILightningApi.Channel): Promise<void> {
 | 
			
		||||
  public async $saveChannel(channel: ILightningApi.Channel, status = 1): Promise<void> {
 | 
			
		||||
    const [ txid, vout ] = channel.chan_point.split(':');
 | 
			
		||||
 | 
			
		||||
    const policy1: Partial<ILightningApi.RoutingPolicy> = channel.node1_policy || {};
 | 
			
		||||
@ -369,11 +376,11 @@ class ChannelsApi {
 | 
			
		||||
        node2_min_htlc_mtokens,
 | 
			
		||||
        node2_updated_at
 | 
			
		||||
      )
 | 
			
		||||
      VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 | 
			
		||||
      VALUES (?, ?, ?, ?, ?, ?, ${status}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 | 
			
		||||
      ON DUPLICATE KEY UPDATE
 | 
			
		||||
        capacity = ?,
 | 
			
		||||
        updated_at = ?,
 | 
			
		||||
        status = 1,
 | 
			
		||||
        status = ${status},
 | 
			
		||||
        node1_public_key = ?,
 | 
			
		||||
        node1_base_fee_mtokens = ?,
 | 
			
		||||
        node1_cltv_delta = ?,
 | 
			
		||||
 | 
			
		||||
@ -169,7 +169,7 @@ class NodesApi {
 | 
			
		||||
      let query: string;
 | 
			
		||||
      if (full === false) {
 | 
			
		||||
        query = `
 | 
			
		||||
          SELECT nodes.public_key, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
 | 
			
		||||
          SELECT nodes.public_key as publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
 | 
			
		||||
            node_stats.channels
 | 
			
		||||
          FROM node_stats
 | 
			
		||||
          JOIN nodes ON nodes.public_key = node_stats.public_key
 | 
			
		||||
@ -296,19 +296,24 @@ class NodesApi {
 | 
			
		||||
 | 
			
		||||
        if (!ispList[isp1]) {
 | 
			
		||||
          ispList[isp1] = {
 | 
			
		||||
            id: channel.isp1ID,
 | 
			
		||||
            id: channel.isp1ID.toString(),
 | 
			
		||||
            capacity: 0,
 | 
			
		||||
            channels: 0,
 | 
			
		||||
            nodes: {},
 | 
			
		||||
          };
 | 
			
		||||
        } else if (ispList[isp1].id.indexOf(channel.isp1ID) === -1) {
 | 
			
		||||
          ispList[isp1].id += ',' + channel.isp1ID.toString();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!ispList[isp2]) {
 | 
			
		||||
          ispList[isp2] = {
 | 
			
		||||
            id: channel.isp2ID,
 | 
			
		||||
            id: channel.isp2ID.toString(),
 | 
			
		||||
            capacity: 0,
 | 
			
		||||
            channels: 0,
 | 
			
		||||
            nodes: {},
 | 
			
		||||
          };
 | 
			
		||||
        } else if (ispList[isp2].id.indexOf(channel.isp2ID) === -1) {
 | 
			
		||||
          ispList[isp2].id += ',' + channel.isp2ID.toString();
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        ispList[isp1].capacity += channel.capacity;
 | 
			
		||||
@ -385,9 +390,10 @@ class NodesApi {
 | 
			
		||||
  public async $getNodesPerCountry(countryId: string) {
 | 
			
		||||
    try {
 | 
			
		||||
      const query = `
 | 
			
		||||
      SELECT nodes.public_key, CAST(COALESCE(node_stats.capacity, 0) as INT) as capacity, CAST(COALESCE(node_stats.channels, 0) as INT) as channels,
 | 
			
		||||
      nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at,
 | 
			
		||||
          geo_names_city.names as city
 | 
			
		||||
        SELECT nodes.public_key, CAST(COALESCE(node_stats.capacity, 0) as INT) as capacity, CAST(COALESCE(node_stats.channels, 0) as INT) as channels,
 | 
			
		||||
          nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at,
 | 
			
		||||
          geo_names_city.names as city, geo_names_country.names as country,
 | 
			
		||||
          geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision
 | 
			
		||||
        FROM node_stats
 | 
			
		||||
        JOIN (
 | 
			
		||||
          SELECT public_key, MAX(added) as last_added
 | 
			
		||||
@ -395,15 +401,19 @@ class NodesApi {
 | 
			
		||||
          GROUP BY public_key
 | 
			
		||||
        ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added
 | 
			
		||||
        RIGHT JOIN nodes ON nodes.public_key = node_stats.public_key
 | 
			
		||||
        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_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
 | 
			
		||||
        LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
 | 
			
		||||
        LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
 | 
			
		||||
        LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division'
 | 
			
		||||
        WHERE geo_names_country.id = ?
 | 
			
		||||
        ORDER BY capacity DESC
 | 
			
		||||
      `;
 | 
			
		||||
 | 
			
		||||
      const [rows]: any = await DB.query(query, [countryId]);
 | 
			
		||||
      for (let i = 0; i < rows.length; ++i) {
 | 
			
		||||
        rows[i].country = JSON.parse(rows[i].country);
 | 
			
		||||
        rows[i].city = JSON.parse(rows[i].city);
 | 
			
		||||
        rows[i].subdivision = JSON.parse(rows[i].subdivision);
 | 
			
		||||
      }
 | 
			
		||||
      return rows;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
@ -417,7 +427,8 @@ class NodesApi {
 | 
			
		||||
      const query = `
 | 
			
		||||
        SELECT nodes.public_key, CAST(COALESCE(node_stats.capacity, 0) as INT) as capacity, CAST(COALESCE(node_stats.channels, 0) as INT) as channels,
 | 
			
		||||
          nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at,
 | 
			
		||||
          geo_names_city.names as city, geo_names_country.names as country
 | 
			
		||||
          geo_names_city.names as city, geo_names_country.names as country,
 | 
			
		||||
          geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision
 | 
			
		||||
        FROM node_stats
 | 
			
		||||
        JOIN (
 | 
			
		||||
          SELECT public_key, MAX(added) as last_added
 | 
			
		||||
@ -425,8 +436,10 @@ class NodesApi {
 | 
			
		||||
          GROUP BY public_key
 | 
			
		||||
        ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added
 | 
			
		||||
        RIGHT JOIN nodes ON nodes.public_key = node_stats.public_key
 | 
			
		||||
        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_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
 | 
			
		||||
        LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
 | 
			
		||||
        LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
 | 
			
		||||
        LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division'
 | 
			
		||||
        WHERE nodes.as_number IN (?)
 | 
			
		||||
        ORDER BY capacity DESC
 | 
			
		||||
      `;
 | 
			
		||||
@ -435,6 +448,7 @@ class NodesApi {
 | 
			
		||||
      for (let i = 0; i < rows.length; ++i) {
 | 
			
		||||
        rows[i].country = JSON.parse(rows[i].country);
 | 
			
		||||
        rows[i].city = JSON.parse(rows[i].city);
 | 
			
		||||
        rows[i].subdivision = JSON.parse(rows[i].subdivision);
 | 
			
		||||
      }
 | 
			
		||||
      return rows;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
 | 
			
		||||
@ -189,7 +189,7 @@ class Server {
 | 
			
		||||
      await networkSyncService.$startService();
 | 
			
		||||
      await lightningStatsUpdater.$startService();
 | 
			
		||||
    } catch(e) {
 | 
			
		||||
      logger.err(`Lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`);
 | 
			
		||||
      logger.err(`Nodejs lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`);
 | 
			
		||||
      await Common.sleep$(1000 * 60);
 | 
			
		||||
      this.$runLightningBackend();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
@ -240,8 +240,8 @@ class NetworkSyncService {
 | 
			
		||||
    let progress = 0;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      logger.info(`Starting closed channels scan...`);
 | 
			
		||||
      const channels = await channelsApi.$getChannelsByStatus(0);
 | 
			
		||||
      logger.info(`Starting closed channels scan`);
 | 
			
		||||
      const channels = await channelsApi.$getChannelsByStatus([0, 1]);
 | 
			
		||||
      for (const channel of channels) {
 | 
			
		||||
        const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout);
 | 
			
		||||
        if (spendingTx.spent === true && spendingTx.status?.confirmed === true) {
 | 
			
		||||
 | 
			
		||||
@ -71,9 +71,7 @@ class FundingTxFetcher {
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  public async $fetchChannelOpenTx(channelId: string): Promise<{timestamp: number, txid: string, value: number}> {
 | 
			
		||||
    if (channelId.indexOf('x') === -1) {
 | 
			
		||||
      channelId = Common.channelIntegerIdToShortId(channelId);
 | 
			
		||||
    }
 | 
			
		||||
    channelId = Common.channelIntegerIdToShortId(channelId);
 | 
			
		||||
 | 
			
		||||
    if (this.fundingTxCache[channelId]) {
 | 
			
		||||
      return this.fundingTxCache[channelId];
 | 
			
		||||
 | 
			
		||||
@ -5,33 +5,11 @@ import fundingTxFetcher from './funding-tx-fetcher';
 | 
			
		||||
import config from '../../../config';
 | 
			
		||||
import { ILightningApi } from '../../../api/lightning/lightning-api.interface';
 | 
			
		||||
import { isIP } from 'net';
 | 
			
		||||
import { Common } from '../../../api/common';
 | 
			
		||||
import channelsApi from '../../../api/explorer/channels.api';
 | 
			
		||||
 | 
			
		||||
const fsPromises = promises;
 | 
			
		||||
 | 
			
		||||
interface Node {
 | 
			
		||||
  id: string;
 | 
			
		||||
  timestamp: number;
 | 
			
		||||
  features: string;
 | 
			
		||||
  rgb_color: string;
 | 
			
		||||
  alias: string;
 | 
			
		||||
  addresses: unknown[];
 | 
			
		||||
  out_degree: number;
 | 
			
		||||
  in_degree: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Channel {
 | 
			
		||||
  channel_id: string;
 | 
			
		||||
  node1_pub: string;
 | 
			
		||||
  node2_pub: string;
 | 
			
		||||
  timestamp: number;
 | 
			
		||||
  features: string;
 | 
			
		||||
  fee_base_msat: number;
 | 
			
		||||
  fee_rate_milli_msat: number;
 | 
			
		||||
  htlc_minimim_msat: number;
 | 
			
		||||
  cltv_expiry_delta: number;
 | 
			
		||||
  htlc_maximum_msat: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class LightningStatsImporter {
 | 
			
		||||
  topologiesFolder = config.LIGHTNING.TOPOLOGY_FOLDER;
 | 
			
		||||
 | 
			
		||||
@ -46,7 +24,8 @@ class LightningStatsImporter {
 | 
			
		||||
  /**
 | 
			
		||||
   * Generate LN network stats for one day
 | 
			
		||||
   */
 | 
			
		||||
  public async computeNetworkStats(timestamp: number, networkGraph: ILightningApi.NetworkGraph): Promise<unknown> {
 | 
			
		||||
  public async computeNetworkStats(timestamp: number,
 | 
			
		||||
    networkGraph: ILightningApi.NetworkGraph, isHistorical: boolean = false): Promise<unknown> {
 | 
			
		||||
    // Node counts and network shares
 | 
			
		||||
    let clearnetNodes = 0;
 | 
			
		||||
    let torNodes = 0;
 | 
			
		||||
@ -59,11 +38,11 @@ class LightningStatsImporter {
 | 
			
		||||
      let isUnnanounced = true;
 | 
			
		||||
 | 
			
		||||
      for (const socket of (node.addresses ?? [])) {
 | 
			
		||||
        if (!socket.network?.length || !socket.addr?.length) {
 | 
			
		||||
        if (!socket.network?.length && !socket.addr?.length) {
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
        hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1;
 | 
			
		||||
        hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0]));
 | 
			
		||||
        hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1 || socket.addr.indexOf('torv2') !== -1 || socket.addr.indexOf('torv3') !== -1;
 | 
			
		||||
        hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0])) || socket.addr.indexOf('ipv4') !== -1 || socket.addr.indexOf('ipv6') !== -1;;
 | 
			
		||||
      }
 | 
			
		||||
      if (hasOnion && hasClearnet) {
 | 
			
		||||
        clearnetTorNodes++;
 | 
			
		||||
@ -90,11 +69,14 @@ class LightningStatsImporter {
 | 
			
		||||
    const baseFees: number[] = [];
 | 
			
		||||
    const alreadyCountedChannels = {};
 | 
			
		||||
    
 | 
			
		||||
    const [channelsInDbRaw]: any[] = await DB.query(`SELECT short_id, created FROM channels`);
 | 
			
		||||
    const channelsInDb = {};
 | 
			
		||||
    for (const channel of channelsInDbRaw) {
 | 
			
		||||
      channelsInDb[channel.short_id] = channel;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (const channel of networkGraph.edges) {
 | 
			
		||||
      let short_id = channel.channel_id;
 | 
			
		||||
      if (short_id.indexOf('/') !== -1) {
 | 
			
		||||
        short_id = short_id.slice(0, -2);
 | 
			
		||||
      }
 | 
			
		||||
      const short_id = Common.channelIntegerIdToShortId(channel.channel_id);
 | 
			
		||||
 | 
			
		||||
      const tx = await fundingTxFetcher.$fetchChannelOpenTx(short_id);
 | 
			
		||||
      if (!tx) {
 | 
			
		||||
@ -102,6 +84,31 @@ class LightningStatsImporter {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Channel is already in db, check if we need to update 'created' field
 | 
			
		||||
      if (isHistorical === true) {
 | 
			
		||||
        //@ts-ignore
 | 
			
		||||
        if (channelsInDb[short_id] && channel.timestamp < channel.created) {
 | 
			
		||||
          await DB.query(`
 | 
			
		||||
            UPDATE channels SET created = FROM_UNIXTIME(?) WHERE channels.short_id = ?`,
 | 
			
		||||
            //@ts-ignore
 | 
			
		||||
            [channel.timestamp, short_id]
 | 
			
		||||
          );
 | 
			
		||||
        } else if (!channelsInDb[short_id]) {
 | 
			
		||||
          await channelsApi.$saveChannel({
 | 
			
		||||
            channel_id: short_id,
 | 
			
		||||
            chan_point: `${tx.txid}:${short_id.split('x')[2]}`,
 | 
			
		||||
            //@ts-ignore
 | 
			
		||||
            last_update: channel.timestamp,
 | 
			
		||||
            node1_pub: channel.node1_pub,
 | 
			
		||||
            node2_pub: channel.node2_pub,
 | 
			
		||||
            capacity: (tx.value * 100000000).toString(),
 | 
			
		||||
            node1_policy: null,
 | 
			
		||||
            node2_policy: null,
 | 
			
		||||
          }, 0);
 | 
			
		||||
          channelsInDb[channel.channel_id] = channel;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!nodeStats[channel.node1_pub]) {
 | 
			
		||||
        nodeStats[channel.node1_pub] = {
 | 
			
		||||
          capacity: 0,
 | 
			
		||||
@ -126,7 +133,7 @@ class LightningStatsImporter {
 | 
			
		||||
        nodeStats[channel.node2_pub].channels++;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (channel.node1_policy !== undefined) { // Coming from the node
 | 
			
		||||
      if (isHistorical === false) { // Coming from the node
 | 
			
		||||
        for (const policy of [channel.node1_policy, channel.node2_policy]) {
 | 
			
		||||
          if (policy && parseInt(policy.fee_rate_milli_msat, 10) < 5000) {
 | 
			
		||||
            avgFeeRate += parseInt(policy.fee_rate_milli_msat, 10);
 | 
			
		||||
@ -137,30 +144,42 @@ class LightningStatsImporter {
 | 
			
		||||
            baseFees.push(parseInt(policy.fee_base_msat, 10));
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      } else { // Coming from the historical import
 | 
			
		||||
      } else {
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        if (channel.fee_rate_milli_msat < 5000) {
 | 
			
		||||
        if (channel.node1_policy.fee_rate_milli_msat < 5000) {
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          avgFeeRate += parseInt(channel.fee_rate_milli_msat, 10);
 | 
			
		||||
          avgFeeRate += parseInt(channel.node1_policy.fee_rate_milli_msat, 10);
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          feeRates.push(parseInt(channel.fee_rate_milli_msat), 10);
 | 
			
		||||
          feeRates.push(parseInt(channel.node1_policy.fee_rate_milli_msat), 10);
 | 
			
		||||
        }
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        if (channel.fee_base_msat < 5000) {
 | 
			
		||||
        if (channel.node1_policy.fee_base_msat < 5000) {
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          avgBaseFee += parseInt(channel.fee_base_msat, 10);
 | 
			
		||||
          avgBaseFee += parseInt(channel.node1_policy.fee_base_msat, 10);
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          baseFees.push(parseInt(channel.fee_base_msat), 10);
 | 
			
		||||
          baseFees.push(parseInt(channel.node1_policy.fee_base_msat), 10);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let medCapacity = 0;
 | 
			
		||||
    let medFeeRate = 0;
 | 
			
		||||
    let medBaseFee = 0;
 | 
			
		||||
    let avgCapacity = 0;
 | 
			
		||||
 | 
			
		||||
    avgFeeRate /= Math.max(networkGraph.edges.length, 1);
 | 
			
		||||
    avgBaseFee /= Math.max(networkGraph.edges.length, 1);
 | 
			
		||||
    const medCapacity = capacities.sort((a, b) => b - a)[Math.round(capacities.length / 2 - 1)];
 | 
			
		||||
    const medFeeRate = feeRates.sort((a, b) => b - a)[Math.round(feeRates.length / 2 - 1)];
 | 
			
		||||
    const medBaseFee = baseFees.sort((a, b) => b - a)[Math.round(baseFees.length / 2 - 1)];
 | 
			
		||||
    const avgCapacity = Math.round(capacity / Math.max(capacities.length, 1));
 | 
			
		||||
 | 
			
		||||
    if (capacities.length > 0) {
 | 
			
		||||
      medCapacity = capacities.sort((a, b) => b - a)[Math.round(capacities.length / 2 - 1)];
 | 
			
		||||
      avgCapacity = Math.round(capacity / Math.max(capacities.length, 1));
 | 
			
		||||
    }
 | 
			
		||||
    if (feeRates.length > 0) {
 | 
			
		||||
      medFeeRate = feeRates.sort((a, b) => b - a)[Math.round(feeRates.length / 2 - 1)];
 | 
			
		||||
    }
 | 
			
		||||
    if (baseFees.length > 0) {
 | 
			
		||||
      medBaseFee = baseFees.sort((a, b) => b - a)[Math.round(baseFees.length / 2 - 1)];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let query = `INSERT INTO lightning_stats(
 | 
			
		||||
        added,
 | 
			
		||||
@ -262,83 +281,154 @@ class LightningStatsImporter {
 | 
			
		||||
   * Import topology files LN historical data into the database
 | 
			
		||||
   */
 | 
			
		||||
  async $importHistoricalLightningStats(): Promise<void> {
 | 
			
		||||
    const fileList = await fsPromises.readdir(this.topologiesFolder);
 | 
			
		||||
    // Insert history from the most recent to the oldest
 | 
			
		||||
    // This also put the .json cached files first
 | 
			
		||||
    fileList.sort().reverse();
 | 
			
		||||
 | 
			
		||||
    const [rows]: any[] = await DB.query(`
 | 
			
		||||
      SELECT UNIX_TIMESTAMP(added) AS added, node_count
 | 
			
		||||
      FROM lightning_stats
 | 
			
		||||
      ORDER BY added DESC
 | 
			
		||||
    `);
 | 
			
		||||
    const existingStatsTimestamps = {};
 | 
			
		||||
    for (const row of rows) {
 | 
			
		||||
      existingStatsTimestamps[row.added] = row;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // For logging purpose
 | 
			
		||||
    let processed = 10;
 | 
			
		||||
    let totalProcessed = 0;
 | 
			
		||||
    let logStarted = false;
 | 
			
		||||
 | 
			
		||||
    for (const filename of fileList) {
 | 
			
		||||
      processed++;
 | 
			
		||||
 | 
			
		||||
      const timestamp = parseInt(filename.split('_')[1], 10);
 | 
			
		||||
 | 
			
		||||
      // Stats exist already, don't calculate/insert them
 | 
			
		||||
      if (existingStatsTimestamps[timestamp] !== undefined) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (filename.indexOf('.topology') === -1) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      logger.debug(`Reading ${this.topologiesFolder}/${filename}`);
 | 
			
		||||
      let fileContent = '';
 | 
			
		||||
    try {
 | 
			
		||||
      let fileList: string[] = [];
 | 
			
		||||
      try {
 | 
			
		||||
        fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8');
 | 
			
		||||
      } catch (e: any) {
 | 
			
		||||
        if (e.errno == -1) { // EISDIR - Ignore directorie
 | 
			
		||||
        fileList = await fsPromises.readdir(this.topologiesFolder);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        logger.err(`Unable to open topology folder at ${this.topologiesFolder}`);
 | 
			
		||||
        throw e;
 | 
			
		||||
      }
 | 
			
		||||
      // Insert history from the most recent to the oldest
 | 
			
		||||
      // This also put the .json cached files first
 | 
			
		||||
      fileList.sort().reverse();
 | 
			
		||||
 | 
			
		||||
      const [rows]: any[] = await DB.query(`
 | 
			
		||||
        SELECT UNIX_TIMESTAMP(added) AS added, node_count
 | 
			
		||||
        FROM lightning_stats
 | 
			
		||||
        ORDER BY added DESC
 | 
			
		||||
      `);
 | 
			
		||||
      const existingStatsTimestamps = {};
 | 
			
		||||
      for (const row of rows) {
 | 
			
		||||
        existingStatsTimestamps[row.added] = row;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // For logging purpose
 | 
			
		||||
      let processed = 10;
 | 
			
		||||
      let totalProcessed = 0;
 | 
			
		||||
      let logStarted = false;
 | 
			
		||||
 | 
			
		||||
      for (const filename of fileList) {
 | 
			
		||||
        processed++;
 | 
			
		||||
 | 
			
		||||
        const timestamp = parseInt(filename.split('_')[1], 10);
 | 
			
		||||
 | 
			
		||||
        // Stats exist already, don't calculate/insert them
 | 
			
		||||
        if (existingStatsTimestamps[timestamp] !== undefined) {
 | 
			
		||||
          totalProcessed++;
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (filename.indexOf('topology_') === -1) {
 | 
			
		||||
          totalProcessed++;
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        logger.debug(`Reading ${this.topologiesFolder}/${filename}`);
 | 
			
		||||
        let fileContent = '';
 | 
			
		||||
        try {
 | 
			
		||||
          fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8');
 | 
			
		||||
        } catch (e: any) {
 | 
			
		||||
          if (e.errno == -1) { // EISDIR - Ignore directorie
 | 
			
		||||
            continue;
 | 
			
		||||
          }
 | 
			
		||||
          logger.err(`Unable to open ${this.topologiesFolder}/${filename}`);
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let graph;
 | 
			
		||||
        try {
 | 
			
		||||
          graph = JSON.parse(fileContent);
 | 
			
		||||
          graph = await this.cleanupTopology(graph);
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`);
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
        if (!logStarted) {
 | 
			
		||||
          logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`);
 | 
			
		||||
          logStarted = true;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
 | 
			
		||||
        logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`);
 | 
			
		||||
 | 
			
		||||
        totalProcessed++;
 | 
			
		||||
 | 
			
		||||
        if (processed > 10) {
 | 
			
		||||
          logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
 | 
			
		||||
          processed = 0;
 | 
			
		||||
        } else {
 | 
			
		||||
          logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
 | 
			
		||||
        }
 | 
			
		||||
        await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2)));
 | 
			
		||||
        const stat = await this.computeNetworkStats(timestamp, graph, true);
 | 
			
		||||
 | 
			
		||||
        existingStatsTimestamps[timestamp] = stat;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      let graph;
 | 
			
		||||
      try {
 | 
			
		||||
        graph = JSON.parse(fileContent);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`);
 | 
			
		||||
      if (totalProcessed > 0) {
 | 
			
		||||
        logger.info(`Lightning network stats historical import completed`);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      logger.err(`Lightning network stats historical failed. Reason: ${e instanceof Error ? e.message : e}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async cleanupTopology(graph) {
 | 
			
		||||
    const newGraph = {
 | 
			
		||||
      nodes: <ILightningApi.Node[]>[],
 | 
			
		||||
      edges: <ILightningApi.Channel[]>[],
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    for (const node of graph.nodes) {
 | 
			
		||||
      const addressesParts = (node.addresses ?? '').split(',');
 | 
			
		||||
      const addresses: any[] = [];
 | 
			
		||||
      for (const address of addressesParts) {
 | 
			
		||||
        addresses.push({
 | 
			
		||||
          network: '',
 | 
			
		||||
          addr: address
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      newGraph.nodes.push({
 | 
			
		||||
        last_update: node.timestamp ?? 0,
 | 
			
		||||
        pub_key: node.id ?? null,
 | 
			
		||||
        alias: node.alias ?? null,
 | 
			
		||||
        addresses: addresses,
 | 
			
		||||
        color: node.rgb_color ?? null,
 | 
			
		||||
        features: {},
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (const adjacency of graph.adjacency) {
 | 
			
		||||
      if (adjacency.length === 0) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
  
 | 
			
		||||
      if (!logStarted) {
 | 
			
		||||
        logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`);
 | 
			
		||||
        logStarted = true;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
 | 
			
		||||
      logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`);
 | 
			
		||||
 | 
			
		||||
      totalProcessed++;
 | 
			
		||||
 | 
			
		||||
      if (processed > 10) {
 | 
			
		||||
        logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
 | 
			
		||||
        processed = 0;
 | 
			
		||||
      } else {
 | 
			
		||||
        logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
 | 
			
		||||
        for (const edge of adjacency) {
 | 
			
		||||
          newGraph.edges.push({
 | 
			
		||||
            channel_id: edge.scid,
 | 
			
		||||
            chan_point: '',
 | 
			
		||||
            last_update: edge.timestamp,
 | 
			
		||||
            node1_pub: edge.source ?? null,
 | 
			
		||||
            node2_pub: edge.destination ?? null,
 | 
			
		||||
            capacity: '0', // Will be fetch later
 | 
			
		||||
            node1_policy: {
 | 
			
		||||
              time_lock_delta: edge.cltv_expiry_delta,
 | 
			
		||||
              min_htlc: edge.htlc_minimim_msat,
 | 
			
		||||
              fee_base_msat: edge.fee_base_msat,
 | 
			
		||||
              fee_rate_milli_msat: edge.fee_proportional_millionths,
 | 
			
		||||
              max_htlc_msat: edge.htlc_maximum_msat,
 | 
			
		||||
              last_update: edge.timestamp,
 | 
			
		||||
              disabled: false,          
 | 
			
		||||
            },
 | 
			
		||||
            node2_policy: null,
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2)));
 | 
			
		||||
      const stat = await this.computeNetworkStats(timestamp, graph);
 | 
			
		||||
 | 
			
		||||
      existingStatsTimestamps[timestamp] = stat;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (totalProcessed > 0) {
 | 
			
		||||
      logger.info(`Lightning network stats historical import completed`);
 | 
			
		||||
    }
 | 
			
		||||
    return newGraph;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,7 @@
 | 
			
		||||
      <div class="col">
 | 
			
		||||
        <table class="table table-borderless smaller-text table-sm table-tx-vin">
 | 
			
		||||
          <tbody>
 | 
			
		||||
            <ng-template ngFor let-vin [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length>12)?tx.vin.slice(0, 10): tx.vin.slice(0, 12)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
 | 
			
		||||
            <ng-template ngFor let-vin [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length > rowLimit) ? tx.vin.slice(0, rowLimit - 2) : tx.vin.slice(0, rowLimit)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
 | 
			
		||||
              <tr [ngClass]="{
 | 
			
		||||
                'assetBox': assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded,
 | 
			
		||||
                'highlight': vin.prevout?.scriptpubkey_address === this.address && this.address !== ''
 | 
			
		||||
@ -146,9 +146,9 @@
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </ng-template>
 | 
			
		||||
            <tr *ngIf="tx.vin.length > 12 && tx['@vinLimit']">
 | 
			
		||||
            <tr *ngIf="tx.vin.length > rowLimit && tx['@vinLimit']">
 | 
			
		||||
              <td colspan="3" class="text-center">
 | 
			
		||||
                <button class="btn btn-sm btn-primary mt-2" (click)="loadMoreInputs(tx);"><span i18n="show-all">Show all</span> ({{ tx.vin.length - 10 }})</button>
 | 
			
		||||
                <button class="btn btn-sm btn-primary mt-2" (click)="loadMoreInputs(tx);"><span i18n="show-all">Show all</span> ({{ tx.vin.length }})</button>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </tbody>
 | 
			
		||||
@ -158,7 +158,7 @@
 | 
			
		||||
      <div class="col mobile-bottomcol">
 | 
			
		||||
        <table class="table table-borderless smaller-text table-sm table-tx-vout">
 | 
			
		||||
          <tbody>
 | 
			
		||||
            <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx['@voutLimit'] && !outputIndex ? ((tx.vout.length > 12) ? tx.vout.slice(0, 10) : tx.vout.slice(0, 12)) : tx.vout" [ngForTrackBy]="trackByIndexFn">
 | 
			
		||||
            <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx['@voutLimit'] && !outputIndex ? ((tx.vout.length > rowLimit) ? tx.vout.slice(0, rowLimit - 2) : tx.vout.slice(0, rowLimit)) : tx.vout" [ngForTrackBy]="trackByIndexFn">
 | 
			
		||||
              <tr [ngClass]="{
 | 
			
		||||
                'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex,
 | 
			
		||||
                'highlight': vout.scriptpubkey_address === this.address && this.address !== ''
 | 
			
		||||
@ -257,9 +257,9 @@
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </ng-template>
 | 
			
		||||
            <tr *ngIf="tx.vout.length > 12 && tx['@voutLimit'] && !outputIndex">
 | 
			
		||||
            <tr *ngIf="tx.vout.length > rowLimit && tx['@voutLimit'] && !outputIndex">
 | 
			
		||||
              <td colspan="3" class="text-center">
 | 
			
		||||
                <button class="btn btn-sm btn-primary mt-2" (click)="tx['@voutLimit'] = false;"><span i18n="show-all">Show all</span> ({{ tx.vout.length - 10 }})</button>
 | 
			
		||||
                <button class="btn btn-sm btn-primary mt-2" (click)="tx['@voutLimit'] = false;"><span i18n="show-all">Show all</span> ({{ tx.vout.length }})</button>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </tbody>
 | 
			
		||||
 | 
			
		||||
@ -26,6 +26,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
 | 
			
		||||
  @Input() paginated = false;
 | 
			
		||||
  @Input() outputIndex: number;
 | 
			
		||||
  @Input() address: string = '';
 | 
			
		||||
  @Input() rowLimit = 12;
 | 
			
		||||
  @Input() channels: { inputs: any[], outputs: any[] };
 | 
			
		||||
 | 
			
		||||
  @Output() loadMore = new EventEmitter();
 | 
			
		||||
 | 
			
		||||
@ -36,7 +38,6 @@ export class TransactionsListComponent implements OnInit, OnChanges {
 | 
			
		||||
  showDetails$ = new BehaviorSubject<boolean>(false);
 | 
			
		||||
  outspends: Outspend[][] = [];
 | 
			
		||||
  assetsMinimal: any;
 | 
			
		||||
  channels: { inputs: any[], outputs: any[] };
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    public stateService: StateService,
 | 
			
		||||
@ -127,7 +128,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
 | 
			
		||||
    });
 | 
			
		||||
    const txIds = this.transactions.map((tx) => tx.txid);
 | 
			
		||||
    this.refreshOutspends$.next(txIds);
 | 
			
		||||
    this.refreshChannels$.next(txIds);
 | 
			
		||||
    if (!this.channels) {
 | 
			
		||||
      this.refreshChannels$.next(txIds);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onScroll() {
 | 
			
		||||
 | 
			
		||||
@ -16,3 +16,9 @@
 | 
			
		||||
  color: #ffffff66;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  .box {
 | 
			
		||||
    margin-bottom: 20px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -14,7 +14,7 @@
 | 
			
		||||
 | 
			
		||||
  <div class="clearfix"></div>
 | 
			
		||||
 | 
			
		||||
  <app-nodes-channels-map *ngIf="!error" [style]="'channelpage'" [channel]="channelGeo"></app-nodes-channels-map>
 | 
			
		||||
  <app-nodes-channels-map *ngIf="!error && (channelGeo$ | async) as channelGeo" [style]="'channelpage'" [channel]="channelGeo"></app-nodes-channels-map>
 | 
			
		||||
 | 
			
		||||
  <div class="box">
 | 
			
		||||
 | 
			
		||||
@ -30,32 +30,6 @@
 | 
			
		||||
                <td i18n="address.total-sent">Last update</td>
 | 
			
		||||
                <td><app-timestamp [dateString]="channel.updated_at"></app-timestamp></td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="address.total-sent">Opening transaction</td>
 | 
			
		||||
                <td>
 | 
			
		||||
                  <a [routerLink]="['/tx' | relativeUrl, channel.transaction_id + ':' + channel.transaction_vout]" >
 | 
			
		||||
                    <span>{{ channel.transaction_id | shortenString : 10 }}</span>
 | 
			
		||||
                  </a>
 | 
			
		||||
                  <app-clipboard [text]="channel.transaction_id"></app-clipboard>
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <ng-template [ngIf]="channel.closing_transaction_id">
 | 
			
		||||
                <tr *ngIf="channel.closing_transaction_id">
 | 
			
		||||
                  <td i18n="address.total-sent">Closing transaction</td>
 | 
			
		||||
                  <td>
 | 
			
		||||
                    <a [routerLink]="['/tx' | relativeUrl, channel.closing_transaction_id]" >
 | 
			
		||||
                      <span>{{ channel.closing_transaction_id | shortenString : 10 }}</span>
 | 
			
		||||
                    </a>
 | 
			
		||||
                    <app-clipboard [text]="channel.closing_transaction_id"></app-clipboard>
 | 
			
		||||
                  </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                <tr>
 | 
			
		||||
                  <td i18n="address.total-sent">Closing type</td>
 | 
			
		||||
                  <td>
 | 
			
		||||
                    <app-closing-type [type]="channel.closing_reason"></app-closing-type>
 | 
			
		||||
                  </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
              </ng-template>
 | 
			
		||||
            </tbody>
 | 
			
		||||
          </table>
 | 
			
		||||
        </div>
 | 
			
		||||
@ -82,8 +56,23 @@
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col">
 | 
			
		||||
        <app-channel-box [channel]="channel.node_right"></app-channel-box>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
    
 | 
			
		||||
    <br>
 | 
			
		||||
 | 
			
		||||
    <ng-container *ngIf="transactions$ | async as transactions">
 | 
			
		||||
      <ng-template [ngIf]="transactions[0]">
 | 
			
		||||
        <h3>Opening transaction</h3>
 | 
			
		||||
        <app-transactions-list [transactions]="[transactions[0]]" [showConfirmations]="true" [rowLimit]="5" [channels]="{ inputs: [], outputs: [channel] }"></app-transactions-list>
 | 
			
		||||
      </ng-template>
 | 
			
		||||
      <ng-template [ngIf]="transactions[1]">
 | 
			
		||||
        <div class="closing-header">
 | 
			
		||||
          <h3 style="margin: 0;">Closing transaction</h3>  <app-closing-type [type]="channel.closing_reason"></app-closing-type>
 | 
			
		||||
        </div>
 | 
			
		||||
        <app-transactions-list [transactions]="[transactions[1]]" [showConfirmations]="true" [rowLimit]="5" [channels]="{ inputs: [channel], outputs: [] }"></app-transactions-list>
 | 
			
		||||
      </ng-template>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -39,3 +39,16 @@ app-fiat {
 | 
			
		||||
    margin-left: 10px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.closing-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  h3 {
 | 
			
		||||
    font-size: 1.4rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,9 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 | 
			
		||||
import { ActivatedRoute, ParamMap } from '@angular/router';
 | 
			
		||||
import { Observable, of } from 'rxjs';
 | 
			
		||||
import { catchError, switchMap, tap } from 'rxjs/operators';
 | 
			
		||||
import { forkJoin, Observable, of, share, zip } from 'rxjs';
 | 
			
		||||
import { catchError, map, shareReplay, switchMap, tap } from 'rxjs/operators';
 | 
			
		||||
import { ApiService } from 'src/app/services/api.service';
 | 
			
		||||
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
 | 
			
		||||
@ -13,13 +15,15 @@ import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
})
 | 
			
		||||
export class ChannelComponent implements OnInit {
 | 
			
		||||
  channel$: Observable<any>;
 | 
			
		||||
  channelGeo$: Observable<number[]>;
 | 
			
		||||
  transactions$: Observable<any>;
 | 
			
		||||
  error: any = null;
 | 
			
		||||
  channelGeo: number[] = [];
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private lightningApiService: LightningApiService,
 | 
			
		||||
    private activatedRoute: ActivatedRoute,
 | 
			
		||||
    private seoService: SeoService,
 | 
			
		||||
    private electrsApiService: ElectrsApiService,
 | 
			
		||||
  ) { }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
@ -30,28 +34,41 @@ export class ChannelComponent implements OnInit {
 | 
			
		||||
          this.seoService.setTitle(`Channel: ${params.get('short_id')}`);
 | 
			
		||||
          return this.lightningApiService.getChannel$(params.get('short_id'))
 | 
			
		||||
            .pipe(
 | 
			
		||||
              tap((data) => {
 | 
			
		||||
                if (!data.node_left.longitude || !data.node_left.latitude ||
 | 
			
		||||
                  !data.node_right.longitude || !data.node_right.latitude) {
 | 
			
		||||
                  this.channelGeo = [];
 | 
			
		||||
                } else {
 | 
			
		||||
                  this.channelGeo = [
 | 
			
		||||
                    data.node_left.public_key,
 | 
			
		||||
                    data.node_left.alias,
 | 
			
		||||
                    data.node_left.longitude, data.node_left.latitude,
 | 
			
		||||
                    data.node_right.public_key,
 | 
			
		||||
                    data.node_right.alias,
 | 
			
		||||
                    data.node_right.longitude, data.node_right.latitude,
 | 
			
		||||
                  ];
 | 
			
		||||
                }
 | 
			
		||||
              }),
 | 
			
		||||
              catchError((err) => {
 | 
			
		||||
                this.error = err;
 | 
			
		||||
                return of(null);
 | 
			
		||||
              })
 | 
			
		||||
            );
 | 
			
		||||
        })
 | 
			
		||||
        }),
 | 
			
		||||
        shareReplay(),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    this.channelGeo$ = this.channel$.pipe(
 | 
			
		||||
      map((data) => {
 | 
			
		||||
        if (!data.node_left.longitude || !data.node_left.latitude ||
 | 
			
		||||
          !data.node_right.longitude || !data.node_right.latitude) {
 | 
			
		||||
          return [];
 | 
			
		||||
        } else {
 | 
			
		||||
          return [
 | 
			
		||||
            data.node_left.public_key,
 | 
			
		||||
            data.node_left.alias,
 | 
			
		||||
            data.node_left.longitude, data.node_left.latitude,
 | 
			
		||||
            data.node_right.public_key,
 | 
			
		||||
            data.node_right.alias,
 | 
			
		||||
            data.node_right.longitude, data.node_right.latitude,
 | 
			
		||||
          ];
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    this.transactions$ = this.channel$.pipe(
 | 
			
		||||
      switchMap((data) => {
 | 
			
		||||
        return zip([
 | 
			
		||||
          data.transaction_id ? this.electrsApiService.getTransaction$(data.transaction_id) : of(null),
 | 
			
		||||
          data.closing_transaction_id ? this.electrsApiService.getTransaction$(data.closing_transaction_id) : of(null),
 | 
			
		||||
        ]);
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,11 @@
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
  
 | 
			
		||||
  <ngb-pagination *ngIf="response.channels.length > 0" class="pagination-container float-right" [size]="paginationSize" [collectionSize]="response.totalItems" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
 | 
			
		||||
  <ngb-pagination *ngIf="response.channels.length > 0" class="pagination-container float-right"
 | 
			
		||||
    [size]="paginationSize" [collectionSize]="response.totalItems" [rotate]="true"
 | 
			
		||||
    [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)"
 | 
			
		||||
    [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false">
 | 
			
		||||
  </ngb-pagination>
 | 
			
		||||
 | 
			
		||||
  <table class="table table-borderless" *ngIf="response.channels.length === 0">
 | 
			
		||||
    <div class="d-flex justify-content-center" i18n="lightning.empty-channels-list">No channels to display</div>
 | 
			
		||||
 | 
			
		||||
@ -47,7 +47,7 @@ export class ChannelsListComponent implements OnInit, OnChanges {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnChanges(): void {
 | 
			
		||||
    this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: false });
 | 
			
		||||
    this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: true });
 | 
			
		||||
    this.channelsPage$.next(1);
 | 
			
		||||
 | 
			
		||||
    this.channels$ = merge(
 | 
			
		||||
@ -70,7 +70,7 @@ export class ChannelsListComponent implements OnInit, OnChanges {
 | 
			
		||||
      map((response) => {
 | 
			
		||||
        return {
 | 
			
		||||
          channels: response.body,
 | 
			
		||||
          totalItems: parseInt(response.headers.get('x-total-count'), 10) + 1 
 | 
			
		||||
          totalItems: parseInt(response.headers.get('x-total-count'), 10)
 | 
			
		||||
        };
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ import { Observable } from 'rxjs';
 | 
			
		||||
import { catchError, map, switchMap } from 'rxjs/operators';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { OpenGraphService } from 'src/app/services/opengraph.service';
 | 
			
		||||
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
 | 
			
		||||
import { getFlagEmoji } from 'src/app/shared/common.utils';
 | 
			
		||||
import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
import { isMobile } from '../../shared/common.utils';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -42,24 +42,10 @@
 | 
			
		||||
                <app-fiat [value]="node.avgCapacity" digitsInfo="1.0-0"></app-fiat>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr *ngIf="node.country && node.city && node.subdivision">
 | 
			
		||||
            <tr *ngIf="node.geolocation">
 | 
			
		||||
              <td i18n="location">Location</td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <span>{{ node.city.en }}, {{ node.subdivision.en }}</span>
 | 
			
		||||
                <br>
 | 
			
		||||
                <a class="d-flex align-items-center" [routerLink]="['/lightning/nodes/country' | relativeUrl, node.iso_code]">
 | 
			
		||||
                  <span class="link">{{ node.country.en }}</span>
 | 
			
		||||
                   
 | 
			
		||||
                  <span class="flag">{{ node.flag }}</span>
 | 
			
		||||
                </a>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr *ngIf="node.country && !node.city">
 | 
			
		||||
              <td i18n="location">Location</td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <a [routerLink]="['/lightning/nodes/country' | relativeUrl, node.iso_code]">
 | 
			
		||||
                  {{ node.country.en }} {{ node.flag }}
 | 
			
		||||
                </a>
 | 
			
		||||
                <app-geolocation [data]="node.geolocation" [type]="'node'"></app-geolocation>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </tbody>
 | 
			
		||||
 | 
			
		||||
@ -3,9 +3,9 @@ import { ActivatedRoute, ParamMap } from '@angular/router';
 | 
			
		||||
import { Observable } from 'rxjs';
 | 
			
		||||
import { catchError, map, switchMap } from 'rxjs/operators';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
 | 
			
		||||
import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
import { isMobile } from '../../shared/common.utils';
 | 
			
		||||
import { GeolocationData } from 'src/app/shared/components/geolocation/geolocation.component';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-node',
 | 
			
		||||
@ -58,7 +58,6 @@ export class NodeComponent implements OnInit {
 | 
			
		||||
            } else if (socket.indexOf('onion') > -1) {
 | 
			
		||||
              label = 'Tor';
 | 
			
		||||
            }
 | 
			
		||||
            node.flag = getFlagEmoji(node.iso_code);
 | 
			
		||||
            socketsObject.push({
 | 
			
		||||
              label: label,
 | 
			
		||||
              socket: node.public_key + '@' + socket,
 | 
			
		||||
@ -66,6 +65,19 @@ export class NodeComponent implements OnInit {
 | 
			
		||||
          }
 | 
			
		||||
          node.socketsObject = socketsObject;
 | 
			
		||||
          node.avgCapacity = node.capacity / Math.max(1, node.active_channel_count);
 | 
			
		||||
 | 
			
		||||
          if (!node?.country && !node?.city &&
 | 
			
		||||
            !node?.subdivision && !node?.iso) {
 | 
			
		||||
              node.geolocation = null;
 | 
			
		||||
          } else {
 | 
			
		||||
            node.geolocation = <GeolocationData>{
 | 
			
		||||
              country: node.country?.en,
 | 
			
		||||
              city: node.city?.en,
 | 
			
		||||
              subdivision: node.subdivision?.en,
 | 
			
		||||
              iso: node.iso_code,
 | 
			
		||||
            };
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return node;
 | 
			
		||||
        }),
 | 
			
		||||
        catchError(err => {
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,16 @@
 | 
			
		||||
<div [class]="'full-container ' + style + (fitContainer ? ' fit-container' : '')">
 | 
			
		||||
 | 
			
		||||
  <div *ngIf="style === 'graph'" class="card-header">
 | 
			
		||||
    <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
 | 
			
		||||
      <span i18n="lightning.nodes-channels-world-map">Lightning nodes channels world map</span>
 | 
			
		||||
<div *ngIf="channelsObservable | async">
 | 
			
		||||
  <div *ngIf="chartOptions" [class]="'full-container ' + style + (fitContainer ? ' fit-container' : '')">
 | 
			
		||||
    <div *ngIf="style === 'graph'" class="card-header">
 | 
			
		||||
      <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
 | 
			
		||||
        <span i18n="lightning.nodes-channels-world-map">Lightning nodes channels world map</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      <small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)"
 | 
			
		||||
      (chartFinished)="onChartFinished($event)">
 | 
			
		||||
    </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)" (chartFinished)="onChartFinished($event)">
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div *ngIf="!chartOptions && style === 'nodepage'" style="padding-top: 30px"></div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, HostListener, Input, Output, EventEmitter, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core';
 | 
			
		||||
import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter, NgZone, OnInit } from '@angular/core';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { ApiService } from 'src/app/services/api.service';
 | 
			
		||||
import { Observable, switchMap, tap, zip } from 'rxjs';
 | 
			
		||||
@ -16,14 +16,14 @@ import { isMobile } from 'src/app/shared/common.utils';
 | 
			
		||||
  styleUrls: ['./nodes-channels-map.component.scss'],
 | 
			
		||||
  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
			
		||||
})
 | 
			
		||||
export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
export class NodesChannelsMap implements OnInit {
 | 
			
		||||
  @Input() style: 'graph' | 'nodepage' | 'widget' | 'channelpage' = 'graph';
 | 
			
		||||
  @Input() publicKey: string | undefined;
 | 
			
		||||
  @Input() channel: any[] = [];
 | 
			
		||||
  @Input() fitContainer = false;
 | 
			
		||||
  @Output() readyEvent = new EventEmitter();
 | 
			
		||||
 | 
			
		||||
  observable$: Observable<any>;
 | 
			
		||||
  channelsObservable: Observable<any>; 
 | 
			
		||||
 | 
			
		||||
  center: number[] | undefined;
 | 
			
		||||
  zoom: number | undefined;
 | 
			
		||||
@ -31,6 +31,7 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
  channelOpacity = 0.1;
 | 
			
		||||
  channelColor = '#466d9d';
 | 
			
		||||
  channelCurve = 0;
 | 
			
		||||
  nodeSize = 4;
 | 
			
		||||
 | 
			
		||||
  chartInstance = undefined;
 | 
			
		||||
  chartOptions: EChartsOption = {};
 | 
			
		||||
@ -49,8 +50,6 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
  ) {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy(): void {}
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.center = this.style === 'widget' ? [0, 40] : [0, 5];
 | 
			
		||||
    this.zoom = 1.3;
 | 
			
		||||
@ -65,8 +64,12 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
    if (this.style === 'graph') {
 | 
			
		||||
      this.seoService.setTitle($localize`Lightning nodes channels world map`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (['nodepage', 'channelpage'].includes(this.style)) {
 | 
			
		||||
      this.nodeSize = 8;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    this.observable$ = this.activatedRoute.paramMap
 | 
			
		||||
    this.channelsObservable = this.activatedRoute.paramMap
 | 
			
		||||
     .pipe(
 | 
			
		||||
       switchMap((params: ParamMap) => {
 | 
			
		||||
        return zip(
 | 
			
		||||
@ -170,15 +173,8 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
  prepareChartOptions(nodes, channels) {
 | 
			
		||||
    let title: object;
 | 
			
		||||
    if (channels.length === 0) {
 | 
			
		||||
      title = {
 | 
			
		||||
        textStyle: {
 | 
			
		||||
          color: 'grey',
 | 
			
		||||
          fontSize: 15
 | 
			
		||||
        },
 | 
			
		||||
        text: $localize`No geolocation data available`,
 | 
			
		||||
        left: 'center',
 | 
			
		||||
        top: 'center'
 | 
			
		||||
      };
 | 
			
		||||
      this.chartOptions = null;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.chartOptions = {
 | 
			
		||||
@ -214,7 +210,7 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
          data: nodes,
 | 
			
		||||
          coordinateSystem: 'geo',
 | 
			
		||||
          geoIndex: 0,
 | 
			
		||||
          symbolSize: 4,
 | 
			
		||||
          symbolSize: this.nodeSize,
 | 
			
		||||
          tooltip: {
 | 
			
		||||
            show: true,
 | 
			
		||||
            backgroundColor: 'rgba(17, 19, 31, 1)',
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,7 @@ import { download } from 'src/app/shared/graphs.utils';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
 | 
			
		||||
import { isMobile } from 'src/app/shared/common.utils';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-nodes-networks-chart',
 | 
			
		||||
@ -108,19 +109,19 @@ export class NodesNetworksChartComponent implements OnInit {
 | 
			
		||||
      );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  prepareChartOptions(data, maxYAxis) {
 | 
			
		||||
  prepareChartOptions(data, maxYAxis): void {
 | 
			
		||||
    let title: object;
 | 
			
		||||
    if (data.tor_nodes.length === 0) {
 | 
			
		||||
    if (!this.widget && data.tor_nodes.length === 0) {
 | 
			
		||||
      title = {
 | 
			
		||||
        textStyle: {
 | 
			
		||||
          color: 'grey',
 | 
			
		||||
          fontSize: 15
 | 
			
		||||
        },
 | 
			
		||||
        text: $localize`:@@23555386d8af1ff73f297e89dd4af3f4689fb9dd:Indexing blocks`,
 | 
			
		||||
        text: $localize`Indexing in progess`,
 | 
			
		||||
        left: 'center',
 | 
			
		||||
        top: 'top',
 | 
			
		||||
        top: 'center',
 | 
			
		||||
      };
 | 
			
		||||
    } else if (this.widget) {
 | 
			
		||||
    } else if (data.tor_nodes.length > 0) {
 | 
			
		||||
      title = {
 | 
			
		||||
        textStyle: {
 | 
			
		||||
          color: 'grey',
 | 
			
		||||
@ -140,11 +141,11 @@ export class NodesNetworksChartComponent implements OnInit {
 | 
			
		||||
        height: this.widget ? 100 : undefined,
 | 
			
		||||
        top: this.widget ? 10 : 40,
 | 
			
		||||
        bottom: this.widget ? 0 : 70,
 | 
			
		||||
        right: (this.isMobile() && this.widget) ? 35 : this.right,
 | 
			
		||||
        left: (this.isMobile() && this.widget) ? 40 :this.left,
 | 
			
		||||
        right: (isMobile() && this.widget) ? 35 : this.right,
 | 
			
		||||
        left: (isMobile() && this.widget) ? 40 :this.left,
 | 
			
		||||
      },
 | 
			
		||||
      tooltip: {
 | 
			
		||||
        show: !this.isMobile() || !this.widget,
 | 
			
		||||
        show: !isMobile() || !this.widget,
 | 
			
		||||
        trigger: 'axis',
 | 
			
		||||
        axisPointer: {
 | 
			
		||||
          type: 'line'
 | 
			
		||||
@ -157,7 +158,7 @@ export class NodesNetworksChartComponent implements OnInit {
 | 
			
		||||
          align: 'left',
 | 
			
		||||
        },
 | 
			
		||||
        borderColor: '#000',
 | 
			
		||||
        formatter: (ticks) => {
 | 
			
		||||
        formatter: (ticks): string => {
 | 
			
		||||
          let total = 0;
 | 
			
		||||
          const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
 | 
			
		||||
          let tooltip = `<b style="color: white; margin-left: 2px">${date}</b><br>`;
 | 
			
		||||
@ -180,7 +181,7 @@ export class NodesNetworksChartComponent implements OnInit {
 | 
			
		||||
      },
 | 
			
		||||
      xAxis: data.tor_nodes.length === 0 ? undefined : {
 | 
			
		||||
        type: 'time',
 | 
			
		||||
        splitNumber: (this.isMobile() || this.widget) ? 5 : 10,
 | 
			
		||||
        splitNumber: (isMobile() || this.widget) ? 5 : 10,
 | 
			
		||||
        axisLabel: {
 | 
			
		||||
          hideOverlap: true,
 | 
			
		||||
        }
 | 
			
		||||
@ -372,7 +373,7 @@ export class NodesNetworksChartComponent implements OnInit {
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChartInit(ec) {
 | 
			
		||||
  onChartInit(ec): void {
 | 
			
		||||
    if (this.chartInstance !== undefined) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
@ -384,11 +385,7 @@ export class NodesNetworksChartComponent implements OnInit {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isMobile() {
 | 
			
		||||
    return (window.innerWidth <= 767.98);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onSaveChart() {
 | 
			
		||||
  onSaveChart(): void {
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    const prevBottom = this.chartOptions.grid.bottom;
 | 
			
		||||
    const now = new Date();
 | 
			
		||||
 | 
			
		||||
@ -9,7 +9,7 @@ import { StateService } from 'src/app/services/state.service';
 | 
			
		||||
import { download } from 'src/app/shared/graphs.utils';
 | 
			
		||||
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
 | 
			
		||||
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
 | 
			
		||||
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
 | 
			
		||||
import { getFlagEmoji } from 'src/app/shared/common.utils';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-nodes-per-country-chart',
 | 
			
		||||
 | 
			
		||||
@ -36,7 +36,7 @@
 | 
			
		||||
            {{ node.channels }}
 | 
			
		||||
          </td>
 | 
			
		||||
          <td class="city text-right text-truncate">
 | 
			
		||||
            {{ node?.city?.en ?? '-' }}
 | 
			
		||||
            <app-geolocation [data]="node.geolocation" [type]="'list-country'"></app-geolocation>
 | 
			
		||||
          </td>
 | 
			
		||||
      </tbody>
 | 
			
		||||
    </table>
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,8 @@ import { ActivatedRoute } from '@angular/router';
 | 
			
		||||
import { map, Observable } from 'rxjs';
 | 
			
		||||
import { ApiService } from 'src/app/services/api.service';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
 | 
			
		||||
import { getFlagEmoji } from 'src/app/shared/common.utils';
 | 
			
		||||
import { GeolocationData } from 'src/app/shared/components/geolocation/geolocation.component';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-nodes-per-country',
 | 
			
		||||
@ -29,6 +30,16 @@ export class NodesPerCountry implements OnInit {
 | 
			
		||||
            name: response.country.en,
 | 
			
		||||
            flag: getFlagEmoji(this.route.snapshot.params.country)
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
          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,
 | 
			
		||||
            };
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          this.seoService.setTitle($localize`Lightning nodes in ${this.country.name}`);
 | 
			
		||||
          return response.nodes;
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
@ -154,7 +154,7 @@ export class NodesPerISPChartComponent implements OnInit {
 | 
			
		||||
          },
 | 
			
		||||
          borderColor: '#000',
 | 
			
		||||
          formatter: () => {
 | 
			
		||||
            return `<b style="color: white">${isp[1]} (${isp[6]}%)</b><br>` +
 | 
			
		||||
            return `<b style="color: white">${isp[1]} (${this.sortBy === 'capacity' ? isp[7] : isp[6]}%)</b><br>` +
 | 
			
		||||
              $localize`${isp[4].toString()} nodes<br>` +
 | 
			
		||||
              $localize`${this.amountShortenerPipe.transform(isp[2] / 100000000, 2)} BTC`
 | 
			
		||||
            ;
 | 
			
		||||
 | 
			
		||||
@ -33,7 +33,7 @@
 | 
			
		||||
            {{ node.channels }}
 | 
			
		||||
          </td>
 | 
			
		||||
          <td class="city text-right text-truncate">
 | 
			
		||||
            {{ node?.city?.en ?? '-' }}
 | 
			
		||||
            <app-geolocation [data]="node.geolocation" [type]="'list-isp'"></app-geolocation>
 | 
			
		||||
          </td>
 | 
			
		||||
      </tbody>
 | 
			
		||||
    </table>
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@ import { ActivatedRoute } from '@angular/router';
 | 
			
		||||
import { map, Observable } from 'rxjs';
 | 
			
		||||
import { ApiService } from 'src/app/services/api.service';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { GeolocationData } from 'src/app/shared/components/geolocation/geolocation.component';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-nodes-per-isp',
 | 
			
		||||
@ -29,6 +30,16 @@ export class NodesPerISP implements OnInit {
 | 
			
		||||
            id: this.route.snapshot.params.isp
 | 
			
		||||
          };
 | 
			
		||||
          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,
 | 
			
		||||
            };
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return response.nodes;
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
@ -48,4 +48,10 @@
 | 
			
		||||
    <div class="spinner-border text-light"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div *ngIf="widget && (capacityObservable$ | async) as stats">
 | 
			
		||||
    <div *ngIf="stats.days === 0" class="indexing-message d-flex" i18n="lightning.indexing-in-progress">
 | 
			
		||||
      Indexing in progress
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
@ -131,4 +131,13 @@
 | 
			
		||||
  display: block;
 | 
			
		||||
  max-width: 80px;
 | 
			
		||||
  margin: 15px auto 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.indexing-message {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
  color: grey;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  margin-left: calc(50% - 85px);
 | 
			
		||||
  margin-top: -10px;
 | 
			
		||||
}
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core';
 | 
			
		||||
import { EChartsOption, graphic } from 'echarts';
 | 
			
		||||
import { Observable } from 'rxjs';
 | 
			
		||||
import { map, startWith, switchMap, tap } from 'rxjs/operators';
 | 
			
		||||
import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { formatNumber } from '@angular/common';
 | 
			
		||||
import { FormBuilder, FormGroup } from '@angular/forms';
 | 
			
		||||
@ -10,6 +10,7 @@ import { MiningService } from 'src/app/services/mining.service';
 | 
			
		||||
import { download } from 'src/app/shared/graphs.utils';
 | 
			
		||||
import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
 | 
			
		||||
import { isMobile } from 'src/app/shared/common.utils';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-lightning-statistics-chart',
 | 
			
		||||
@ -96,12 +97,13 @@ export class LightningStatisticsChartComponent implements OnInit {
 | 
			
		||||
              }),
 | 
			
		||||
            );
 | 
			
		||||
        }),
 | 
			
		||||
      )
 | 
			
		||||
        share(),
 | 
			
		||||
      );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  prepareChartOptions(data) {
 | 
			
		||||
  prepareChartOptions(data): void {
 | 
			
		||||
    let title: object;
 | 
			
		||||
    if (data.channel_count.length === 0) {
 | 
			
		||||
    if (!this.widget && data.channel_count.length === 0) {
 | 
			
		||||
      title = {
 | 
			
		||||
        textStyle: {
 | 
			
		||||
          color: 'grey',
 | 
			
		||||
@ -111,7 +113,7 @@ export class LightningStatisticsChartComponent implements OnInit {
 | 
			
		||||
        left: 'center',
 | 
			
		||||
        top: 'center'
 | 
			
		||||
      };
 | 
			
		||||
    } else if (this.widget) {
 | 
			
		||||
    } else if (data.channel_count.length > 0) {
 | 
			
		||||
      title = {
 | 
			
		||||
        textStyle: {
 | 
			
		||||
          color: 'grey',
 | 
			
		||||
@ -138,11 +140,11 @@ export class LightningStatisticsChartComponent implements OnInit {
 | 
			
		||||
        height: this.widget ? 100 : undefined,
 | 
			
		||||
        top: this.widget ? 10 : 40,
 | 
			
		||||
        bottom: this.widget ? 0 : 70,
 | 
			
		||||
        right: (this.isMobile() && this.widget) ? 35 : this.right,
 | 
			
		||||
        left: (this.isMobile() && this.widget) ? 40 :this.left,
 | 
			
		||||
        right: (isMobile() && this.widget) ? 35 : this.right,
 | 
			
		||||
        left: (isMobile() && this.widget) ? 40 :this.left,
 | 
			
		||||
      },
 | 
			
		||||
      tooltip: {
 | 
			
		||||
        show: !this.isMobile(),
 | 
			
		||||
        show: !isMobile(),
 | 
			
		||||
        trigger: 'axis',
 | 
			
		||||
        axisPointer: {
 | 
			
		||||
          type: 'line'
 | 
			
		||||
@ -155,7 +157,7 @@ export class LightningStatisticsChartComponent implements OnInit {
 | 
			
		||||
          align: 'left',
 | 
			
		||||
        },
 | 
			
		||||
        borderColor: '#000',
 | 
			
		||||
        formatter: (ticks) => {
 | 
			
		||||
        formatter: (ticks): string => {
 | 
			
		||||
          let sizeString = '';
 | 
			
		||||
          let weightString = '';
 | 
			
		||||
 | 
			
		||||
@ -169,16 +171,18 @@ export class LightningStatisticsChartComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
          const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
 | 
			
		||||
 | 
			
		||||
          let tooltip = `<b style="color: white; margin-left: 18px">${date}</b><br>
 | 
			
		||||
          const tooltip = `
 | 
			
		||||
            <b style="color: white; margin-left: 18px">${date}</b><br>
 | 
			
		||||
            <span>${sizeString}</span><br>
 | 
			
		||||
            <span>${weightString}</span>`;
 | 
			
		||||
            <span>${weightString}</span>
 | 
			
		||||
          `;
 | 
			
		||||
 | 
			
		||||
          return tooltip;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      xAxis: data.channel_count.length === 0 ? undefined : {
 | 
			
		||||
        type: 'time',
 | 
			
		||||
        splitNumber: (this.isMobile() || this.widget) ? 5 : 10,
 | 
			
		||||
        splitNumber: (isMobile() || this.widget) ? 5 : 10,
 | 
			
		||||
        axisLabel: {
 | 
			
		||||
          hideOverlap: true,
 | 
			
		||||
        }
 | 
			
		||||
@ -315,7 +319,7 @@ export class LightningStatisticsChartComponent implements OnInit {
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChartInit(ec) {
 | 
			
		||||
  onChartInit(ec): void {
 | 
			
		||||
    if (this.chartInstance !== undefined) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
@ -327,11 +331,7 @@ export class LightningStatisticsChartComponent implements OnInit {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isMobile() {
 | 
			
		||||
    return (window.innerWidth <= 767.98);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onSaveChart() {
 | 
			
		||||
  onSaveChart(): void {
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    const prevBottom = this.chartOptions.grid.bottom;
 | 
			
		||||
    const now = new Date();
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,120 @@
 | 
			
		||||
export function isMobile() {
 | 
			
		||||
export function isMobile(): boolean {
 | 
			
		||||
  return (window.innerWidth <= 767.98);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getFlagEmoji(countryCode): string {
 | 
			
		||||
  if (!countryCode) {
 | 
			
		||||
    return '';
 | 
			
		||||
  }
 | 
			
		||||
  const codePoints = countryCode
 | 
			
		||||
    .toUpperCase()
 | 
			
		||||
    .split('')
 | 
			
		||||
    .map(char => 127397 + char.charCodeAt());
 | 
			
		||||
  return String.fromCodePoint(...codePoints);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// https://gist.github.com/calebgrove/c285a9510948b633aa47
 | 
			
		||||
export function convertRegion(input, to: 'name' | 'abbreviated'): string {
 | 
			
		||||
  if (!input) {
 | 
			
		||||
    return '';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const states = [
 | 
			
		||||
    ['Alabama', 'AL'],
 | 
			
		||||
    ['Alaska', 'AK'],
 | 
			
		||||
    ['American Samoa', 'AS'],
 | 
			
		||||
    ['Arizona', 'AZ'],
 | 
			
		||||
    ['Arkansas', 'AR'],
 | 
			
		||||
    ['Armed Forces Americas', 'AA'],
 | 
			
		||||
    ['Armed Forces Europe', 'AE'],
 | 
			
		||||
    ['Armed Forces Pacific', 'AP'],
 | 
			
		||||
    ['California', 'CA'],
 | 
			
		||||
    ['Colorado', 'CO'],
 | 
			
		||||
    ['Connecticut', 'CT'],
 | 
			
		||||
    ['Delaware', 'DE'],
 | 
			
		||||
    ['District Of Columbia', 'DC'],
 | 
			
		||||
    ['Florida', 'FL'],
 | 
			
		||||
    ['Georgia', 'GA'],
 | 
			
		||||
    ['Guam', 'GU'],
 | 
			
		||||
    ['Hawaii', 'HI'],
 | 
			
		||||
    ['Idaho', 'ID'],
 | 
			
		||||
    ['Illinois', 'IL'],
 | 
			
		||||
    ['Indiana', 'IN'],
 | 
			
		||||
    ['Iowa', 'IA'],
 | 
			
		||||
    ['Kansas', 'KS'],
 | 
			
		||||
    ['Kentucky', 'KY'],
 | 
			
		||||
    ['Louisiana', 'LA'],
 | 
			
		||||
    ['Maine', 'ME'],
 | 
			
		||||
    ['Marshall Islands', 'MH'],
 | 
			
		||||
    ['Maryland', 'MD'],
 | 
			
		||||
    ['Massachusetts', 'MA'],
 | 
			
		||||
    ['Michigan', 'MI'],
 | 
			
		||||
    ['Minnesota', 'MN'],
 | 
			
		||||
    ['Mississippi', 'MS'],
 | 
			
		||||
    ['Missouri', 'MO'],
 | 
			
		||||
    ['Montana', 'MT'],
 | 
			
		||||
    ['Nebraska', 'NE'],
 | 
			
		||||
    ['Nevada', 'NV'],
 | 
			
		||||
    ['New Hampshire', 'NH'],
 | 
			
		||||
    ['New Jersey', 'NJ'],
 | 
			
		||||
    ['New Mexico', 'NM'],
 | 
			
		||||
    ['New York', 'NY'],
 | 
			
		||||
    ['North Carolina', 'NC'],
 | 
			
		||||
    ['North Dakota', 'ND'],
 | 
			
		||||
    ['Northern Mariana Islands', 'NP'],
 | 
			
		||||
    ['Ohio', 'OH'],
 | 
			
		||||
    ['Oklahoma', 'OK'],
 | 
			
		||||
    ['Oregon', 'OR'],
 | 
			
		||||
    ['Pennsylvania', 'PA'],
 | 
			
		||||
    ['Puerto Rico', 'PR'],
 | 
			
		||||
    ['Rhode Island', 'RI'],
 | 
			
		||||
    ['South Carolina', 'SC'],
 | 
			
		||||
    ['South Dakota', 'SD'],
 | 
			
		||||
    ['Tennessee', 'TN'],
 | 
			
		||||
    ['Texas', 'TX'],
 | 
			
		||||
    ['US Virgin Islands', 'VI'],
 | 
			
		||||
    ['Utah', 'UT'],
 | 
			
		||||
    ['Vermont', 'VT'],
 | 
			
		||||
    ['Virginia', 'VA'],
 | 
			
		||||
    ['Washington', 'WA'],
 | 
			
		||||
    ['West Virginia', 'WV'],
 | 
			
		||||
    ['Wisconsin', 'WI'],
 | 
			
		||||
    ['Wyoming', 'WY'],
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  // So happy that Canada and the US have distinct abbreviations
 | 
			
		||||
  const provinces = [
 | 
			
		||||
    ['Alberta', 'AB'],
 | 
			
		||||
    ['British Columbia', 'BC'],
 | 
			
		||||
    ['Manitoba', 'MB'],
 | 
			
		||||
    ['New Brunswick', 'NB'],
 | 
			
		||||
    ['Newfoundland', 'NF'],
 | 
			
		||||
    ['Northwest Territory', 'NT'],
 | 
			
		||||
    ['Nova Scotia', 'NS'],
 | 
			
		||||
    ['Nunavut', 'NU'],
 | 
			
		||||
    ['Ontario', 'ON'],
 | 
			
		||||
    ['Prince Edward Island', 'PE'],
 | 
			
		||||
    ['Quebec', 'QC'],
 | 
			
		||||
    ['Saskatchewan', 'SK'],
 | 
			
		||||
    ['Yukon', 'YT'],
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  const regions = states.concat(provinces);
 | 
			
		||||
 | 
			
		||||
  let i; // Reusable loop variable
 | 
			
		||||
  if (to == 'abbreviated') {
 | 
			
		||||
    input = input.replace(/\w\S*/g, function (txt) { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); });
 | 
			
		||||
    for (i = 0; i < regions.length; i++) {
 | 
			
		||||
      if (regions[i][0] == input) {
 | 
			
		||||
        return (regions[i][1]);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  } else if (to == 'name') {
 | 
			
		||||
    input = input.toUpperCase();
 | 
			
		||||
    for (i = 0; i < regions.length; i++) {
 | 
			
		||||
      if (regions[i][1] == input) {
 | 
			
		||||
        return (regions[i][0]);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1 @@
 | 
			
		||||
<span [innerHTML]="formattedLocation"></span>
 | 
			
		||||
@ -0,0 +1,83 @@
 | 
			
		||||
import { Component, Input, OnChanges } from '@angular/core';
 | 
			
		||||
import { convertRegion, getFlagEmoji } from '../../common.utils';
 | 
			
		||||
 | 
			
		||||
export interface GeolocationData {
 | 
			
		||||
  country: string;
 | 
			
		||||
  city: string;
 | 
			
		||||
  subdivision: string;
 | 
			
		||||
  iso: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-geolocation',
 | 
			
		||||
  templateUrl: './geolocation.component.html',
 | 
			
		||||
  styleUrls: ['./geolocation.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class GeolocationComponent implements OnChanges {
 | 
			
		||||
  @Input() data: GeolocationData;
 | 
			
		||||
  @Input() type: 'node' | 'list-isp' | 'list-country';
 | 
			
		||||
 | 
			
		||||
  formattedLocation: string = '';
 | 
			
		||||
 | 
			
		||||
  ngOnChanges(): void {
 | 
			
		||||
    const city = this.data.city ? this.data.city : '';
 | 
			
		||||
    const subdivisionLikeCity = this.data.city === this.data.subdivision;
 | 
			
		||||
    let subdivision = this.data.subdivision;
 | 
			
		||||
 | 
			
		||||
    if (['US', 'CA'].includes(this.data.iso) === false || (this.type === 'node' && subdivisionLikeCity)) {
 | 
			
		||||
      this.data.subdivision = undefined;
 | 
			
		||||
    } else if (['list-isp', 'list-country'].includes(this.type) === true) {
 | 
			
		||||
      subdivision = convertRegion(this.data.subdivision, 'abbreviated');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.type === 'list-country') {
 | 
			
		||||
      if (this.data.city) {
 | 
			
		||||
        this.formattedLocation += ' ' + city;
 | 
			
		||||
        if (this.data.subdivision) {
 | 
			
		||||
          this.formattedLocation += ', ' + subdivision;
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        this.formattedLocation += '-';
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.type === 'list-isp') {
 | 
			
		||||
      this.formattedLocation = getFlagEmoji(this.data.iso);
 | 
			
		||||
      if (this.data.city) {
 | 
			
		||||
        this.formattedLocation += ' ' + city;
 | 
			
		||||
        if (this.data.subdivision) {
 | 
			
		||||
          this.formattedLocation += ', ' + subdivision;
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        this.formattedLocation += ' ' + this.data.country;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (this.type === 'node') {
 | 
			
		||||
      const city = this.data.city ? this.data.city : '';
 | 
			
		||||
 | 
			
		||||
      // City
 | 
			
		||||
      this.formattedLocation = `${city}`;
 | 
			
		||||
 | 
			
		||||
      // ,Subdivision
 | 
			
		||||
      if (this.formattedLocation.length > 0 && !subdivisionLikeCity) {
 | 
			
		||||
        this.formattedLocation += ', ';
 | 
			
		||||
      }
 | 
			
		||||
      if (!subdivisionLikeCity) {
 | 
			
		||||
        this.formattedLocation += `${subdivision}`;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // <br>[flag] County
 | 
			
		||||
      if (this.data?.country.length ?? 0 > 0) {
 | 
			
		||||
        if ((this.formattedLocation?.length ?? 0 > 0) && !subdivisionLikeCity) {
 | 
			
		||||
          this.formattedLocation += '<br>';
 | 
			
		||||
        } else if (this.data.city) {
 | 
			
		||||
          this.formattedLocation += ', ';
 | 
			
		||||
        }
 | 
			
		||||
        this.formattedLocation += `${this.data.country} ${getFlagEmoji(this.data.iso)}`;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -91,13 +91,3 @@ export function detectWebGL() {
 | 
			
		||||
  return (gl && gl instanceof WebGLRenderingContext);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getFlagEmoji(countryCode) {
 | 
			
		||||
  if (!countryCode) {
 | 
			
		||||
    return '';
 | 
			
		||||
  }
 | 
			
		||||
  const codePoints = countryCode
 | 
			
		||||
    .toUpperCase()
 | 
			
		||||
    .split('')
 | 
			
		||||
    .map(char =>  127397 + char.charCodeAt());
 | 
			
		||||
  return String.fromCodePoint(...codePoints);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -82,6 +82,7 @@ import { SatsComponent } from './components/sats/sats.component';
 | 
			
		||||
import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component';
 | 
			
		||||
import { TimestampComponent } from './components/timestamp/timestamp.component';
 | 
			
		||||
import { ToggleComponent } from './components/toggle/toggle.component';
 | 
			
		||||
import { GeolocationComponent } from '../shared/components/geolocation/geolocation.component';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
  declarations: [
 | 
			
		||||
@ -158,6 +159,7 @@ import { ToggleComponent } from './components/toggle/toggle.component';
 | 
			
		||||
    SearchResultsComponent,
 | 
			
		||||
    TimestampComponent,
 | 
			
		||||
    ToggleComponent,
 | 
			
		||||
    GeolocationComponent,
 | 
			
		||||
  ],
 | 
			
		||||
  imports: [
 | 
			
		||||
    CommonModule,
 | 
			
		||||
@ -261,6 +263,7 @@ import { ToggleComponent } from './components/toggle/toggle.component';
 | 
			
		||||
    SearchResultsComponent,
 | 
			
		||||
    TimestampComponent,
 | 
			
		||||
    ToggleComponent,
 | 
			
		||||
    GeolocationComponent,
 | 
			
		||||
  ]
 | 
			
		||||
})
 | 
			
		||||
export class SharedModule {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user