Merge branch 'master' into fix/difficulty-api
This commit is contained in:
		
						commit
						00bd61e1d3
					
				@ -77,13 +77,19 @@
 | 
			
		||||
  },
 | 
			
		||||
  "LIGHTNING": {
 | 
			
		||||
    "ENABLED": false,
 | 
			
		||||
    "BACKEND": "lnd"
 | 
			
		||||
    "BACKEND": "lnd",
 | 
			
		||||
    "STATS_REFRESH_INTERVAL": 600,
 | 
			
		||||
    "GRAPH_REFRESH_INTERVAL": 600,
 | 
			
		||||
    "LOGGER_UPDATE_INTERVAL": 30
 | 
			
		||||
  },
 | 
			
		||||
  "LND": {
 | 
			
		||||
    "TLS_CERT_PATH": "tls.cert",
 | 
			
		||||
    "MACAROON_PATH": "readonly.macaroon",
 | 
			
		||||
    "REST_API_URL": "https://localhost:8080"
 | 
			
		||||
  },
 | 
			
		||||
  "CLIGHTNING": {
 | 
			
		||||
    "SOCKET": "lightning-rpc"
 | 
			
		||||
  },
 | 
			
		||||
  "SOCKS5PROXY": {
 | 
			
		||||
    "ENABLED": false,
 | 
			
		||||
    "USE_ONION": true,
 | 
			
		||||
 | 
			
		||||
@ -88,5 +88,21 @@
 | 
			
		||||
    "LIQUID_ONION": "__EXTERNAL_DATA_SERVER_LIQUID_ONION__",
 | 
			
		||||
    "BISQ_URL": "__EXTERNAL_DATA_SERVER_BISQ_URL__",
 | 
			
		||||
    "BISQ_ONION": "__EXTERNAL_DATA_SERVER_BISQ_ONION__"
 | 
			
		||||
  },
 | 
			
		||||
  "LIGHTNING": {
 | 
			
		||||
    "ENABLED": "__LIGHTNING_ENABLED__",
 | 
			
		||||
    "BACKEND": "__LIGHTNING_BACKEND__",
 | 
			
		||||
    "TOPOLOGY_FOLDER": "__LIGHTNING_TOPOLOGY_FOLDER__",
 | 
			
		||||
    "STATS_REFRESH_INTERVAL": 600,
 | 
			
		||||
    "GRAPH_REFRESH_INTERVAL": 600,
 | 
			
		||||
    "LOGGER_UPDATE_INTERVAL": 30
 | 
			
		||||
  },
 | 
			
		||||
  "LND": {
 | 
			
		||||
    "TLS_CERT_PATH": "",
 | 
			
		||||
    "MACAROON_PATH": "",
 | 
			
		||||
    "REST_API_URL": "https://localhost:8080"
 | 
			
		||||
  },
 | 
			
		||||
  "CLIGHTNING": {
 | 
			
		||||
    "SOCKET": "__CLIGHTNING_SOCKET__"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ import logger from '../logger';
 | 
			
		||||
import { Common } from './common';
 | 
			
		||||
 | 
			
		||||
class DatabaseMigration {
 | 
			
		||||
  private static currentVersion = 38;
 | 
			
		||||
  private static currentVersion = 39;
 | 
			
		||||
  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'));
 | 
			
		||||
@ -338,6 +337,11 @@ class DatabaseMigration {
 | 
			
		||||
      await this.$executeQuery('ALTER TABLE `lightning_stats` CHANGE `added` `added` timestamp NULL');
 | 
			
		||||
      await this.$executeQuery('ALTER TABLE `node_stats` CHANGE `added` `added` timestamp NULL');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (databaseSchemaVersion < 39 && isBitcoin === true) {
 | 
			
		||||
      await this.$executeQuery('ALTER TABLE `nodes` ADD alias_search TEXT NULL DEFAULT NULL AFTER `alias`');
 | 
			
		||||
      await this.$executeQuery('ALTER TABLE nodes ADD FULLTEXT(alias_search)');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 | 
			
		||||
@ -17,32 +17,60 @@ class ChannelsApi {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async $getAllChannelsGeo(publicKey?: string): Promise<any[]> {
 | 
			
		||||
  public async $getAllChannelsGeo(publicKey?: string, style?: string): Promise<any[]> {
 | 
			
		||||
    try {
 | 
			
		||||
      let select: string;
 | 
			
		||||
      if (style === 'widget') {
 | 
			
		||||
        select = `
 | 
			
		||||
          nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude,
 | 
			
		||||
          nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude
 | 
			
		||||
        `;
 | 
			
		||||
      } else {
 | 
			
		||||
        select = `
 | 
			
		||||
          nodes_1.public_key as node1_public_key, nodes_1.alias AS node1_alias,
 | 
			
		||||
          nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude,
 | 
			
		||||
          nodes_2.public_key as node2_public_key, nodes_2.alias AS node2_alias,
 | 
			
		||||
          nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude
 | 
			
		||||
        `;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const params: string[] = [];
 | 
			
		||||
      let query = `SELECT nodes_1.public_key as node1_public_key, nodes_1.alias AS node1_alias,
 | 
			
		||||
        nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude,
 | 
			
		||||
        nodes_2.public_key as node2_public_key, nodes_2.alias AS node2_alias,
 | 
			
		||||
        nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude,
 | 
			
		||||
        channels.capacity
 | 
			
		||||
      FROM channels
 | 
			
		||||
      JOIN nodes AS nodes_1 on nodes_1.public_key = channels.node1_public_key
 | 
			
		||||
      JOIN nodes AS nodes_2 on nodes_2.public_key = channels.node2_public_key
 | 
			
		||||
      WHERE nodes_1.latitude IS NOT NULL AND nodes_1.longitude IS NOT NULL
 | 
			
		||||
        AND nodes_2.latitude IS NOT NULL AND nodes_2.longitude IS NOT NULL
 | 
			
		||||
      let query = `SELECT ${select}
 | 
			
		||||
        FROM channels
 | 
			
		||||
        JOIN nodes AS nodes_1 on nodes_1.public_key = channels.node1_public_key
 | 
			
		||||
        JOIN nodes AS nodes_2 on nodes_2.public_key = channels.node2_public_key
 | 
			
		||||
        WHERE nodes_1.latitude IS NOT NULL AND nodes_1.longitude IS NOT NULL
 | 
			
		||||
          AND nodes_2.latitude IS NOT NULL AND nodes_2.longitude IS NOT NULL
 | 
			
		||||
      `;
 | 
			
		||||
 | 
			
		||||
      if (publicKey !== undefined) {
 | 
			
		||||
        query += ' AND (nodes_1.public_key = ? OR nodes_2.public_key = ?)';
 | 
			
		||||
        params.push(publicKey);
 | 
			
		||||
        params.push(publicKey);
 | 
			
		||||
      } else {
 | 
			
		||||
        query += ` AND channels.capacity > 1000000
 | 
			
		||||
          GROUP BY nodes_1.public_key, nodes_2.public_key
 | 
			
		||||
          ORDER BY channels.capacity DESC
 | 
			
		||||
          LIMIT 10000
 | 
			
		||||
        `;        
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const [rows]: any = await DB.query(query, params);
 | 
			
		||||
      return rows.map((row) => [
 | 
			
		||||
        row.node1_public_key, row.node1_alias, row.node1_longitude, row.node1_latitude,
 | 
			
		||||
        row.node2_public_key, row.node2_alias, row.node2_longitude, row.node2_latitude,
 | 
			
		||||
        row.capacity]);
 | 
			
		||||
      return rows.map((row) => {
 | 
			
		||||
        if (style === 'widget') {
 | 
			
		||||
          return [
 | 
			
		||||
            row.node1_longitude, row.node1_latitude,
 | 
			
		||||
            row.node2_longitude, row.node2_latitude,
 | 
			
		||||
          ];
 | 
			
		||||
        } else {
 | 
			
		||||
          return [
 | 
			
		||||
            row.node1_public_key, row.node1_alias,
 | 
			
		||||
            row.node1_longitude, row.node1_latitude,
 | 
			
		||||
            row.node2_public_key, row.node2_alias,
 | 
			
		||||
            row.node2_longitude, row.node2_latitude,
 | 
			
		||||
          ];
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      logger.err('$getAllChannelsGeo error: ' + (e instanceof Error ? e.message : e));
 | 
			
		||||
      throw e;
 | 
			
		||||
@ -61,9 +89,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) {
 | 
			
		||||
@ -212,41 +245,53 @@ class ChannelsApi {
 | 
			
		||||
      let channelStatusFilter;
 | 
			
		||||
      if (status === 'open') {
 | 
			
		||||
        channelStatusFilter = '< 2';
 | 
			
		||||
      } else if (status === 'active') {
 | 
			
		||||
        channelStatusFilter = '= 1';
 | 
			
		||||
      } else if (status === 'closed') {
 | 
			
		||||
        channelStatusFilter = '= 2';
 | 
			
		||||
      } else {
 | 
			
		||||
        throw new Error('getChannelsForNode: Invalid status requested');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Channels originating from node
 | 
			
		||||
      let query = `
 | 
			
		||||
        SELECT node2.alias, node2.public_key, channels.status, channels.node1_fee_rate,
 | 
			
		||||
          channels.capacity, channels.short_id, channels.id
 | 
			
		||||
        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, channels.closing_reason
 | 
			
		||||
        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,
 | 
			
		||||
          channels.capacity, channels.short_id, channels.id
 | 
			
		||||
        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, channels.closing_reason
 | 
			
		||||
        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) => {
 | 
			
		||||
        return b.capacity - a.capacity;
 | 
			
		||||
      });
 | 
			
		||||
      allChannels = allChannels.slice(index, index + length);
 | 
			
		||||
 | 
			
		||||
      if (index >= 0) {
 | 
			
		||||
        allChannels = allChannels.slice(index, index + length);
 | 
			
		||||
      } else if (index === -1) { // Node channels tree chart
 | 
			
		||||
        allChannels = allChannels.slice(0, 1000);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const channels: any[] = []
 | 
			
		||||
      for (const row of allChannels) {
 | 
			
		||||
        const activeChannelsStats: any = await nodesApi.$getActiveChannelsStats(row.public_key);
 | 
			
		||||
        channels.push({
 | 
			
		||||
          status: row.status,
 | 
			
		||||
          closing_reason: row.closing_reason,
 | 
			
		||||
          capacity: row.capacity ?? 0,
 | 
			
		||||
          short_id: row.short_id,
 | 
			
		||||
          id: row.id,
 | 
			
		||||
@ -337,7 +382,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 +414,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 = ?,
 | 
			
		||||
 | 
			
		||||
@ -102,7 +102,11 @@ class ChannelsRoutes {
 | 
			
		||||
 | 
			
		||||
  private async $getAllChannelsGeo(req: Request, res: Response) {
 | 
			
		||||
    try {
 | 
			
		||||
      const channels = await channelsApi.$getAllChannelsGeo(req.params?.publicKey);
 | 
			
		||||
      const style: string = typeof req.query.style === 'string' ? req.query.style : '';
 | 
			
		||||
      const channels = await channelsApi.$getAllChannelsGeo(req.params?.publicKey, style);
 | 
			
		||||
      res.header('Pragma', 'public');
 | 
			
		||||
      res.header('Cache-control', 'public');
 | 
			
		||||
      res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
 | 
			
		||||
      res.json(channels);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      res.status(500).send(e instanceof Error ? e.message : e);
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
@ -259,9 +259,10 @@ class NodesApi {
 | 
			
		||||
 | 
			
		||||
  public async $searchNodeByPublicKeyOrAlias(search: string) {
 | 
			
		||||
    try {
 | 
			
		||||
      const searchStripped = search.replace('%', '') + '%';
 | 
			
		||||
      const query = `SELECT nodes.public_key, nodes.alias, node_stats.capacity FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key WHERE nodes.public_key LIKE ? OR nodes.alias LIKE ? GROUP BY nodes.public_key ORDER BY node_stats.capacity DESC LIMIT 10`;
 | 
			
		||||
      const [rows]: any = await DB.query(query, [searchStripped, searchStripped]);
 | 
			
		||||
      const publicKeySearch = search.replace('%', '') + '%';
 | 
			
		||||
      const aliasSearch = search.replace(/[-_.]/g, ' ').replace(/[^a-zA-Z ]/g, '').split(' ').map((search) => '+' + search + '*').join(' ');
 | 
			
		||||
      const query = `SELECT nodes.public_key, nodes.alias, node_stats.capacity FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key WHERE nodes.public_key LIKE ? OR MATCH nodes.alias_search AGAINST (? IN BOOLEAN MODE) GROUP BY nodes.public_key ORDER BY node_stats.capacity DESC LIMIT 10`;
 | 
			
		||||
      const [rows]: any = await DB.query(query, [publicKeySearch, aliasSearch]);
 | 
			
		||||
      return rows;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      logger.err('$searchNodeByPublicKeyOrAlias error: ' + (e instanceof Error ? e.message : e));
 | 
			
		||||
@ -296,19 +297,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 +391,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 +402,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 +428,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 +437,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 +449,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) {
 | 
			
		||||
@ -487,21 +502,24 @@ class NodesApi {
 | 
			
		||||
          first_seen,
 | 
			
		||||
          updated_at,
 | 
			
		||||
          alias,
 | 
			
		||||
          alias_search,
 | 
			
		||||
          color,
 | 
			
		||||
          sockets,
 | 
			
		||||
          status
 | 
			
		||||
        )
 | 
			
		||||
        VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, 1)
 | 
			
		||||
        ON DUPLICATE KEY UPDATE updated_at = FROM_UNIXTIME(?), alias = ?, color = ?, sockets = ?, status = 1`;
 | 
			
		||||
        VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, ?, 1)
 | 
			
		||||
        ON DUPLICATE KEY UPDATE updated_at = FROM_UNIXTIME(?), alias = ?, alias_search = ?, color = ?, sockets = ?, status = 1`;
 | 
			
		||||
 | 
			
		||||
      await DB.query(query, [
 | 
			
		||||
        node.pub_key,
 | 
			
		||||
        node.last_update,
 | 
			
		||||
        node.alias,
 | 
			
		||||
        this.aliasToSearchText(node.alias),
 | 
			
		||||
        node.color,
 | 
			
		||||
        sockets,
 | 
			
		||||
        node.last_update,
 | 
			
		||||
        node.alias,
 | 
			
		||||
        this.aliasToSearchText(node.alias),
 | 
			
		||||
        node.color,
 | 
			
		||||
        sockets,
 | 
			
		||||
      ]);
 | 
			
		||||
@ -535,6 +553,10 @@ class NodesApi {
 | 
			
		||||
      logger.err('$setNodesInactive() error: ' + (e instanceof Error ? e.message : e));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private aliasToSearchText(str: string): string {
 | 
			
		||||
    return str.replace(/[-_.]/g, ' ').replace(/[^a-zA-Z ]/g, '');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default new NodesApi();
 | 
			
		||||
 | 
			
		||||
@ -15,8 +15,8 @@ class NodesRoutes {
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/countries', this.$getNodesCountries)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings', this.$getNodesRanking)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/capacity', this.$getTopNodesByCapacity)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/channels', this.$getTopNodesByChannels)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/liquidity', this.$getTopNodesByCapacity)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/connectivity', this.$getTopNodesByChannels)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/age', this.$getOldestNodes)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)
 | 
			
		||||
 | 
			
		||||
@ -36,6 +36,7 @@ interface IConfig {
 | 
			
		||||
    TOPOLOGY_FOLDER: string;
 | 
			
		||||
    STATS_REFRESH_INTERVAL: number;
 | 
			
		||||
    GRAPH_REFRESH_INTERVAL: number;
 | 
			
		||||
    LOGGER_UPDATE_INTERVAL: number;
 | 
			
		||||
  };
 | 
			
		||||
  LND: {
 | 
			
		||||
    TLS_CERT_PATH: string;
 | 
			
		||||
@ -191,6 +192,7 @@ const defaults: IConfig = {
 | 
			
		||||
    'TOPOLOGY_FOLDER': '',
 | 
			
		||||
    'STATS_REFRESH_INTERVAL': 600,
 | 
			
		||||
    'GRAPH_REFRESH_INTERVAL': 600,
 | 
			
		||||
    'LOGGER_UPDATE_INTERVAL': 30,
 | 
			
		||||
  },
 | 
			
		||||
  'LND': {
 | 
			
		||||
    'TLS_CERT_PATH': '',
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
@ -83,7 +83,7 @@ class NetworkSyncService {
 | 
			
		||||
    logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted`);
 | 
			
		||||
 | 
			
		||||
    // If a channel if not present in the graph, mark it as inactive
 | 
			
		||||
    nodesApi.$setNodesInactive(graphNodesPubkeys);
 | 
			
		||||
    await nodesApi.$setNodesInactive(graphNodesPubkeys);
 | 
			
		||||
 | 
			
		||||
    if (config.MAXMIND.ENABLED) {
 | 
			
		||||
      $lookupNodeLocation();
 | 
			
		||||
@ -95,11 +95,19 @@ class NetworkSyncService {
 | 
			
		||||
   */
 | 
			
		||||
  private async $updateChannelsList(channels: ILightningApi.Channel[]): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      const [closedChannelsRaw]: any[] = await DB.query(`SELECT id FROM channels WHERE status = 2`);
 | 
			
		||||
      const closedChannels = {};
 | 
			
		||||
      for (const closedChannel of closedChannelsRaw) {
 | 
			
		||||
        closedChannels[closedChannel.id] = true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      let progress = 0;
 | 
			
		||||
 | 
			
		||||
      const graphChannelsIds: string[] = [];
 | 
			
		||||
      for (const channel of channels) {
 | 
			
		||||
        await channelsApi.$saveChannel(channel);
 | 
			
		||||
        if (!closedChannels[channel.channel_id]) {
 | 
			
		||||
          await channelsApi.$saveChannel(channel);
 | 
			
		||||
        }
 | 
			
		||||
        graphChannelsIds.push(channel.channel_id);
 | 
			
		||||
        ++progress;
 | 
			
		||||
 | 
			
		||||
@ -113,7 +121,7 @@ class NetworkSyncService {
 | 
			
		||||
      logger.info(`${progress} channels updated`);
 | 
			
		||||
 | 
			
		||||
      // If a channel if not present in the graph, mark it as inactive
 | 
			
		||||
      channelsApi.$setChannelsInactive(graphChannelsIds);
 | 
			
		||||
      await channelsApi.$setChannelsInactive(graphChannelsIds);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      logger.err(`Cannot update channel list. Reason: ${(e instanceof Error ? e.message : e)}`);
 | 
			
		||||
    }
 | 
			
		||||
@ -232,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) {
 | 
			
		||||
@ -277,44 +285,66 @@ class NetworkSyncService {
 | 
			
		||||
      for (const channel of channels) {
 | 
			
		||||
        let reason = 0;
 | 
			
		||||
        // Only Esplora backend can retrieve spent transaction outputs
 | 
			
		||||
        const outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id);
 | 
			
		||||
        const lightningScriptReasons: number[] = [];
 | 
			
		||||
        for (const outspend of outspends) {
 | 
			
		||||
          if (outspend.spent && outspend.txid) {
 | 
			
		||||
            const spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid);
 | 
			
		||||
            const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
 | 
			
		||||
            lightningScriptReasons.push(lightningScript);
 | 
			
		||||
        try {
 | 
			
		||||
          let outspends: IEsploraApi.Outspend[] | undefined;
 | 
			
		||||
          try {
 | 
			
		||||
            outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id);
 | 
			
		||||
          } catch (e) {
 | 
			
		||||
            logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
 | 
			
		||||
            continue;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        if (lightningScriptReasons.length === outspends.length
 | 
			
		||||
          && lightningScriptReasons.filter((r) => r === 1).length === outspends.length) {
 | 
			
		||||
          reason = 1;
 | 
			
		||||
        } else {
 | 
			
		||||
          const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
 | 
			
		||||
          if (filteredReasons.length) {
 | 
			
		||||
            if (filteredReasons.some((r) => r === 2 || r === 4)) {
 | 
			
		||||
              reason = 3;
 | 
			
		||||
            } else {
 | 
			
		||||
              reason = 2;
 | 
			
		||||
          const lightningScriptReasons: number[] = [];
 | 
			
		||||
          for (const outspend of outspends) {
 | 
			
		||||
            if (outspend.spent && outspend.txid) {
 | 
			
		||||
              let spendingTx: IEsploraApi.Transaction | undefined;
 | 
			
		||||
              try {
 | 
			
		||||
                spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid);
 | 
			
		||||
              } catch (e) {
 | 
			
		||||
                logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`);
 | 
			
		||||
                continue;
 | 
			
		||||
              }
 | 
			
		||||
              const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
 | 
			
		||||
              lightningScriptReasons.push(lightningScript);
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          if (lightningScriptReasons.length === outspends.length
 | 
			
		||||
            && lightningScriptReasons.filter((r) => r === 1).length === outspends.length) {
 | 
			
		||||
            reason = 1;
 | 
			
		||||
          } else {
 | 
			
		||||
            /*
 | 
			
		||||
              We can detect a commitment transaction (force close) by reading Sequence and Locktime
 | 
			
		||||
              https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
 | 
			
		||||
            */
 | 
			
		||||
            const closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id);
 | 
			
		||||
            const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
 | 
			
		||||
            const locktimeHex: string = closingTx.locktime.toString(16);
 | 
			
		||||
            if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
 | 
			
		||||
              reason = 2; // Here we can't be sure if it's a penalty or not
 | 
			
		||||
            const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
 | 
			
		||||
            if (filteredReasons.length) {
 | 
			
		||||
              if (filteredReasons.some((r) => r === 2 || r === 4)) {
 | 
			
		||||
                reason = 3;
 | 
			
		||||
              } else {
 | 
			
		||||
                reason = 2;
 | 
			
		||||
              }
 | 
			
		||||
            } else {
 | 
			
		||||
              reason = 1;
 | 
			
		||||
              /*
 | 
			
		||||
                We can detect a commitment transaction (force close) by reading Sequence and Locktime
 | 
			
		||||
                https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
 | 
			
		||||
              */
 | 
			
		||||
              let closingTx: IEsploraApi.Transaction | undefined;
 | 
			
		||||
              try {
 | 
			
		||||
                closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id);
 | 
			
		||||
              } catch (e) {
 | 
			
		||||
                logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`);
 | 
			
		||||
                continue;
 | 
			
		||||
              }
 | 
			
		||||
              const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
 | 
			
		||||
              const locktimeHex: string = closingTx.locktime.toString(16);
 | 
			
		||||
              if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
 | 
			
		||||
                reason = 2; // Here we can't be sure if it's a penalty or not
 | 
			
		||||
              } else {
 | 
			
		||||
                reason = 1;
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        if (reason) {
 | 
			
		||||
          logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
 | 
			
		||||
          await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
 | 
			
		||||
          if (reason) {
 | 
			
		||||
            logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
 | 
			
		||||
            await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
 | 
			
		||||
          }
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ++progress;
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,8 @@ 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;
 | 
			
		||||
 | 
			
		||||
@ -22,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;
 | 
			
		||||
@ -66,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) {
 | 
			
		||||
@ -78,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,
 | 
			
		||||
@ -102,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);
 | 
			
		||||
@ -113,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,
 | 
			
		||||
@ -319,7 +362,7 @@ class LightningStatsImporter {
 | 
			
		||||
          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);
 | 
			
		||||
        const stat = await this.computeNetworkStats(timestamp, graph, true);
 | 
			
		||||
 | 
			
		||||
        existingStatsTimestamps[timestamp] = stat;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -350,3 +350,68 @@ Corresponding `docker-compose.yml` overrides:
 | 
			
		||||
      PRICE_DATA_SERVER_CLEARNET_URL: ""
 | 
			
		||||
      ...
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
<br/>
 | 
			
		||||
 | 
			
		||||
`mempool-config.json`:
 | 
			
		||||
```
 | 
			
		||||
  "LIGHTNING": {
 | 
			
		||||
    "ENABLED": false
 | 
			
		||||
    "BACKEND": "lnd"
 | 
			
		||||
    "TOPOLOGY_FOLDER": ""
 | 
			
		||||
    "STATS_REFRESH_INTERVAL": 600
 | 
			
		||||
    "GRAPH_REFRESH_INTERVAL": 600
 | 
			
		||||
    "LOGGER_UPDATE_INTERVAL": 30
 | 
			
		||||
  }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Corresponding `docker-compose.yml` overrides:
 | 
			
		||||
```
 | 
			
		||||
  api:
 | 
			
		||||
    environment:
 | 
			
		||||
      LIGHTNING_ENABLED: false
 | 
			
		||||
      LIGHTNING_BACKEND: "lnd"
 | 
			
		||||
      LIGHTNING_TOPOLOGY_FOLDER: ""
 | 
			
		||||
      LIGHTNING_STATS_REFRESH_INTERVAL: 600
 | 
			
		||||
      LIGHTNING_GRAPH_REFRESH_INTERVAL: 600
 | 
			
		||||
      LIGHTNING_LOGGER_UPDATE_INTERVAL: 30
 | 
			
		||||
      ...
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
<br/>
 | 
			
		||||
 | 
			
		||||
`mempool-config.json`:
 | 
			
		||||
```
 | 
			
		||||
  "LND": {
 | 
			
		||||
    "TLS_CERT_PATH": ""
 | 
			
		||||
    "MACAROON_PATH": ""
 | 
			
		||||
    "REST_API_URL": "https://localhost:8080"
 | 
			
		||||
  }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Corresponding `docker-compose.yml` overrides:
 | 
			
		||||
```
 | 
			
		||||
  api:
 | 
			
		||||
    environment:
 | 
			
		||||
      LND_TLS_CERT_PATH: ""
 | 
			
		||||
      LND_MACAROON_PATH: ""
 | 
			
		||||
      LND_REST_API_URL: "https://localhost:8080"
 | 
			
		||||
      ...
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
<br/>
 | 
			
		||||
 | 
			
		||||
`mempool-config.json`:
 | 
			
		||||
```
 | 
			
		||||
  "CLN": {
 | 
			
		||||
    "SOCKET": ""
 | 
			
		||||
  }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Corresponding `docker-compose.yml` overrides:
 | 
			
		||||
```
 | 
			
		||||
  api:
 | 
			
		||||
    environment:
 | 
			
		||||
      CLN_SOCKET: ""
 | 
			
		||||
      ...
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
@ -91,6 +91,22 @@ __EXTERNAL_DATA_SERVER_LIQUID_ONION__=${EXTERNAL_DATA_SERVER_LIQUID_ONION:=http:
 | 
			
		||||
__EXTERNAL_DATA_SERVER_BISQ_URL__=${EXTERNAL_DATA_SERVER_BISQ_URL:=https://bisq.markets/api}
 | 
			
		||||
__EXTERNAL_DATA_SERVER_BISQ_ONION__=${EXTERNAL_DATA_SERVER_BISQ_ONION:=http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api}
 | 
			
		||||
 | 
			
		||||
# LIGHTNING
 | 
			
		||||
__LIGHTNING_ENABLED__=${LIGHTNING_ENABLED:=false}
 | 
			
		||||
__LIGHTNING_BACKEND__=${LIGHTNING_BACKEND:="lnd"}
 | 
			
		||||
__LIGHTNING_TOPOLOGY_FOLDER__=${LIGHTNING_TOPOLOGY_FOLDER:=""}
 | 
			
		||||
__LIGHTNING_STATS_REFRESH_INTERVAL__=${LIGHTNING_STATS_REFRESH_INTERVAL:=600}
 | 
			
		||||
__LIGHTNING_GRAPH_REFRESH_INTERVAL__=${LIGHTNING_GRAPH_REFRESH_INTERVAL:=600}
 | 
			
		||||
__LIGHTNING_LOGGER_UPDATE_INTERVAL__=${LIGHTNING_LOGGER_UPDATE_INTERVAL:=30}
 | 
			
		||||
 | 
			
		||||
# LND
 | 
			
		||||
__LND_TLS_CERT_PATH__=${LND_TLS_CERT_PATH:=""}
 | 
			
		||||
__LND_MACAROON_PATH__=${LND_MACAROON_PATH:=""}
 | 
			
		||||
__LND_REST_API_URL__=${LND_REST_API_URL:="https://localhost:8080"}
 | 
			
		||||
 | 
			
		||||
# CLN
 | 
			
		||||
__CLN_SOCKET__=${CLN_SOCKET:=""}
 | 
			
		||||
 | 
			
		||||
mkdir -p "${__MEMPOOL_CACHE_DIR__}"
 | 
			
		||||
 | 
			
		||||
sed -i "s/__MEMPOOL_NETWORK__/${__MEMPOOL_NETWORK__}/g" mempool-config.json
 | 
			
		||||
@ -173,4 +189,20 @@ sed -i "s!__EXTERNAL_DATA_SERVER_LIQUID_ONION__!${__EXTERNAL_DATA_SERVER_LIQUID_
 | 
			
		||||
sed -i "s!__EXTERNAL_DATA_SERVER_BISQ_URL__!${__EXTERNAL_DATA_SERVER_BISQ_URL__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__EXTERNAL_DATA_SERVER_BISQ_ONION__!${__EXTERNAL_DATA_SERVER_BISQ_ONION__}!g" mempool-config.json
 | 
			
		||||
 | 
			
		||||
# LIGHTNING
 | 
			
		||||
sed -i "s!__LIGHTNING_ENABLED__!${__LIGHTNING_ENABLED__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__LIGHTNING_BACKEND__!${__LIGHTNING_BACKEND__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__LIGHTNING_TOPOLOGY_FOLDER__!${__LIGHTNING_TOPOLOGY_FOLDER__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__LIGHTNING_STATS_REFRESH_INTERVAL__!${__LIGHTNING_STATS_REFRESH_INTERVAL__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__LIGHTNING_GRAPH_REFRESH_INTERVAL__!${__LIGHTNING_GRAPH_REFRESH_INTERVAL__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__LIGHTNING_LOGGER_UPDATE_INTERVAL__!${__LIGHTNING_LOGGER_UPDATE_INTERVAL__}!g" mempool-config.json
 | 
			
		||||
 | 
			
		||||
# LND
 | 
			
		||||
sed -i "s!__LND_TLS_CERT_PATH__!${__LND_TLS_CERT_PATH__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__LND_MACAROON_PATH__!${__LND_MACAROON_PATH__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__LND_REST_API_URL__!${__LND_REST_API_URL__}!g" mempool-config.json
 | 
			
		||||
 | 
			
		||||
# CLN
 | 
			
		||||
sed -i "s!__CLN_SOCKET__!${__CLN_SOCKET__}!g" mempool-config.json
 | 
			
		||||
 | 
			
		||||
node /backend/dist/index.js
 | 
			
		||||
 | 
			
		||||
@ -41,7 +41,9 @@
 | 
			
		||||
        <a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="mining.mining-dashboard" title="Mining Dashboard"></fa-icon></a>
 | 
			
		||||
      </li>
 | 
			
		||||
      <li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.LIGHTNING">
 | 
			
		||||
        <a class="nav-link" [routerLink]="['/lightning' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'bolt']" [fixedWidth]="true" i18n-title="master-page.lightning" title="Lightning Explorer"></fa-icon></a>
 | 
			
		||||
        <a class="nav-link" [routerLink]="['/lightning' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'bolt']" [fixedWidth]="true" i18n-title="master-page.lightning" title="Lightning Explorer"></fa-icon>
 | 
			
		||||
          <span class="badge badge-pill badge-warning beta" i18n="beta">beta</span>
 | 
			
		||||
        </a>
 | 
			
		||||
      </li>
 | 
			
		||||
      <li class="nav-item" routerLinkActive="active" id="btn-blocks" *ngIf="!stateService.env.MINING_DASHBOARD">
 | 
			
		||||
        <a class="nav-link" [routerLink]="['/blocks' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'cubes']" [fixedWidth]="true" i18n-title="master-page.blocks" title="Blocks"></fa-icon></a>
 | 
			
		||||
 | 
			
		||||
@ -182,3 +182,14 @@ nav {
 | 
			
		||||
  width: 140px;
 | 
			
		||||
  height: 35px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.beta {
 | 
			
		||||
  display: inline;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  margin: 24px 0px 0px -15px;
 | 
			
		||||
  font-size: 8px;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    margin: 33px 0px 0px -19px;
 | 
			
		||||
    font-size: 7px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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() {
 | 
			
		||||
 | 
			
		||||
@ -188,4 +188,4 @@ export interface IOldestNodes {
 | 
			
		||||
  updatedAt?: number,
 | 
			
		||||
  city?: any,
 | 
			
		||||
  country?: any,
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -16,3 +16,9 @@
 | 
			
		||||
  color: #ffffff66;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  .box {
 | 
			
		||||
    margin-bottom: 20px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
<div class="container-xl" *ngIf="(channel$ | async) as channel">
 | 
			
		||||
  <h5 class="mb-0" style="color: #ffffff66" i18n="lightning.channel">Lightning channel</h5>
 | 
			
		||||
  <div class="title-container">
 | 
			
		||||
    <h1 class="mb-0">{{ channel.short_id }}</h1>
 | 
			
		||||
    <span class="tx-link">
 | 
			
		||||
@ -14,7 +15,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 +31,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 +57,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>
 | 
			
		||||
@ -83,7 +87,7 @@
 | 
			
		||||
</ng-template>
 | 
			
		||||
 | 
			
		||||
<ng-template #skeleton>
 | 
			
		||||
  <h2 class="float-left">Channels</h2>
 | 
			
		||||
  <h2 class="float-left" i18n="lightning.channels">Channels</h2>
 | 
			
		||||
 | 
			
		||||
  <table class="table table-borderless">
 | 
			
		||||
  <ng-container *ngTemplateOutlet="tableHeader"></ng-container>
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
        };
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -32,7 +32,7 @@ export class LightningApiService {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getChannelsByNodeId$(publicKey: string, index: number = 0, status = 'open'): Observable<any> {
 | 
			
		||||
    let params = new HttpParams()
 | 
			
		||||
    const params = new HttpParams()
 | 
			
		||||
      .set('public_key', publicKey)
 | 
			
		||||
      .set('index', index)
 | 
			
		||||
      .set('status', status)
 | 
			
		||||
@ -66,13 +66,13 @@ export class LightningApiService {
 | 
			
		||||
 | 
			
		||||
  getTopNodesByCapacity$(): Observable<ITopNodesPerCapacity[]> {
 | 
			
		||||
    return this.httpClient.get<ITopNodesPerCapacity[]>(
 | 
			
		||||
      this.apiBasePath + '/api/v1/lightning/nodes/rankings/capacity'
 | 
			
		||||
      this.apiBasePath + '/api/v1/lightning/nodes/rankings/liquidity'
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getTopNodesByChannels$(): Observable<ITopNodesPerChannels[]> {
 | 
			
		||||
    return this.httpClient.get<ITopNodesPerChannels[]>(
 | 
			
		||||
      this.apiBasePath + '/api/v1/lightning/nodes/rankings/channels'
 | 
			
		||||
      this.apiBasePath + '/api/v1/lightning/nodes/rankings/connectivity'
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -37,7 +37,7 @@
 | 
			
		||||
      <div class="card graph-card">
 | 
			
		||||
        <div class="card-body pl-2 pr-2">
 | 
			
		||||
          <app-nodes-per-isp-chart [widget]="true"></app-nodes-per-isp-chart>
 | 
			
		||||
          <div class="mt-2"><a [attr.data-cy]="'pool-distribution-view-more'" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
 | 
			
		||||
          <div style="margin-top: 5px"><a [attr.data-cy]="'pool-distribution-view-more'" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
@ -49,17 +49,17 @@
 | 
			
		||||
          <h5 class="card-title mt-3" i18n="lightning.network-history">Lightning network history</h5>
 | 
			
		||||
          <app-lightning-statistics-chart [widget]=true></app-lightning-statistics-chart>
 | 
			
		||||
          <app-nodes-networks-chart [widget]=true></app-nodes-networks-chart>
 | 
			
		||||
          <div class="mt-1"><a [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
 | 
			
		||||
          <div><a [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Top nodes per capacity -->
 | 
			
		||||
    <div class="col">
 | 
			
		||||
      <div class="card">
 | 
			
		||||
      <div class="card" style="height: 409px">
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
          <a class="title-link" href="" [routerLink]="['/lightning/nodes/top-capacity' | relativeUrl]">
 | 
			
		||||
            <h5 class="card-title d-inline" i18n="lightning.top-capacity-nodes">Top capacity nodes</h5>
 | 
			
		||||
          <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/liquidity' | relativeUrl]">
 | 
			
		||||
            <h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity ranking</h5>
 | 
			
		||||
            <span> </span>
 | 
			
		||||
            <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
 | 
			
		||||
          </a>
 | 
			
		||||
@ -70,10 +70,10 @@
 | 
			
		||||
 | 
			
		||||
    <!-- Top nodes per channels -->
 | 
			
		||||
    <div class="col">
 | 
			
		||||
      <div class="card">
 | 
			
		||||
      <div class="card" style="height: 409px">
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
          <a class="title-link" href="" [routerLink]="['/lightning/nodes/top-channels' | relativeUrl]">
 | 
			
		||||
            <h5 class="card-title d-inline" i18n="lightning.top-channels-nodes">Most connected nodes</h5>
 | 
			
		||||
          <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/connectivity' | relativeUrl]">
 | 
			
		||||
            <h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity ranking</h5>
 | 
			
		||||
            <span> </span>
 | 
			
		||||
            <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
 | 
			
		||||
          </a>
 | 
			
		||||
 | 
			
		||||
@ -29,6 +29,7 @@ import { TopNodesPerChannels } from '../lightning/nodes-ranking/top-nodes-per-ch
 | 
			
		||||
import { TopNodesPerCapacity } from '../lightning/nodes-ranking/top-nodes-per-capacity/top-nodes-per-capacity.component';
 | 
			
		||||
import { OldestNodes } from '../lightning/nodes-ranking/oldest-nodes/oldest-nodes.component';
 | 
			
		||||
import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/nodes-rankings-dashboard.component';
 | 
			
		||||
import { NodeChannels } from '../lightning/nodes-channels/node-channels.component';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
  declarations: [
 | 
			
		||||
@ -56,6 +57,7 @@ import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/no
 | 
			
		||||
    TopNodesPerCapacity,
 | 
			
		||||
    OldestNodes,
 | 
			
		||||
    NodesRankingsDashboard,
 | 
			
		||||
    NodeChannels,
 | 
			
		||||
  ],
 | 
			
		||||
  imports: [
 | 
			
		||||
    CommonModule,
 | 
			
		||||
@ -89,6 +91,7 @@ import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/no
 | 
			
		||||
    TopNodesPerCapacity,
 | 
			
		||||
    OldestNodes,
 | 
			
		||||
    NodesRankingsDashboard,
 | 
			
		||||
    NodeChannels,
 | 
			
		||||
  ],
 | 
			
		||||
  providers: [
 | 
			
		||||
    LightningApiService,
 | 
			
		||||
 | 
			
		||||
@ -39,14 +39,14 @@ const routes: Routes = [
 | 
			
		||||
          component: NodesRankingsDashboard,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: 'nodes/top-capacity',
 | 
			
		||||
          path: 'nodes/rankings/liquidity',
 | 
			
		||||
          component: NodesRanking,
 | 
			
		||||
          data: {
 | 
			
		||||
            type: 'capacity'
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: 'nodes/top-channels',
 | 
			
		||||
          path: 'nodes/rankings/connectivity',
 | 
			
		||||
          component: NodesRanking,
 | 
			
		||||
          data: {
 | 
			
		||||
            type: 'channels'
 | 
			
		||||
 | 
			
		||||
@ -1,129 +1,5 @@
 | 
			
		||||
 | 
			
		||||
.main-title {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  color: #ffffff91;
 | 
			
		||||
  margin-top: -13px;
 | 
			
		||||
  font-size: 10px;
 | 
			
		||||
  text-transform: uppercase;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  padding-bottom: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full-container {
 | 
			
		||||
  padding: 0px 15px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  /* min-height: 500px; */
 | 
			
		||||
  height: calc(100% - 150px);
 | 
			
		||||
  @media (max-width: 992px) {
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    padding-bottom: 100px;
 | 
			
		||||
  };
 | 
			
		||||
  margin-top: 25px;
 | 
			
		||||
  margin-bottom: 25px;
 | 
			
		||||
  min-height: 100%;
 | 
			
		||||
}
 | 
			
		||||
/*
 | 
			
		||||
.chart {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  padding-bottom: 20px;
 | 
			
		||||
  padding-right: 10px;
 | 
			
		||||
  @media (max-width: 992px) {
 | 
			
		||||
    padding-bottom: 25px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 829px) {
 | 
			
		||||
    padding-bottom: 50px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 767px) {
 | 
			
		||||
    padding-bottom: 25px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 629px) {
 | 
			
		||||
    padding-bottom: 55px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 567px) {
 | 
			
		||||
    padding-bottom: 55px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
*/
 | 
			
		||||
.chart-widget {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  max-height: 270px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.formRadioGroup {
 | 
			
		||||
  margin-top: 6px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  @media (min-width: 991px) {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    top: -65px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (min-width: 830px) and (max-width: 991px) {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    top: 0px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (min-width: 830px) {
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    float: right;
 | 
			
		||||
    margin-top: 0px;
 | 
			
		||||
  }
 | 
			
		||||
  .btn-sm {
 | 
			
		||||
    font-size: 9px;
 | 
			
		||||
    @media (min-width: 830px) {
 | 
			
		||||
      font-size: 14px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pool-distribution {
 | 
			
		||||
  min-height: 56px;
 | 
			
		||||
  display: block;
 | 
			
		||||
  @media (min-width: 485px) {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
  }
 | 
			
		||||
  h5 {
 | 
			
		||||
    margin-bottom: 10px;
 | 
			
		||||
  }
 | 
			
		||||
  .item {
 | 
			
		||||
    width: 50%;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    margin: 0px auto 20px;
 | 
			
		||||
    &:nth-child(2) {
 | 
			
		||||
      order: 2;
 | 
			
		||||
      @media (min-width: 485px) {
 | 
			
		||||
        order: 3;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    &:nth-child(3) {
 | 
			
		||||
      order: 3;
 | 
			
		||||
      @media (min-width: 485px) {
 | 
			
		||||
        order: 2;
 | 
			
		||||
        display: block;
 | 
			
		||||
      }
 | 
			
		||||
      @media (min-width: 768px) {
 | 
			
		||||
        display: none;
 | 
			
		||||
      }
 | 
			
		||||
      @media (min-width: 992px) {
 | 
			
		||||
        display: block;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    .card-title {
 | 
			
		||||
      font-size: 1rem;
 | 
			
		||||
      color: #4a68b9;
 | 
			
		||||
    }
 | 
			
		||||
    .card-text {
 | 
			
		||||
      font-size: 18px;
 | 
			
		||||
      span {
 | 
			
		||||
        color: #ffffff66;
 | 
			
		||||
        font-size: 12px;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.skeleton-loader {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  display: block;
 | 
			
		||||
  max-width: 80px;
 | 
			
		||||
  margin: 15px auto 3px;
 | 
			
		||||
}
 | 
			
		||||
@ -25,7 +25,7 @@ import { ActivatedRoute, ParamMap } from '@angular/router';
 | 
			
		||||
export class NodeStatisticsChartComponent implements OnInit {
 | 
			
		||||
  @Input() publicKey: string;
 | 
			
		||||
  @Input() right: number | string = 65;
 | 
			
		||||
  @Input() left: number | string = 55;
 | 
			
		||||
  @Input() left: number | string = 45;
 | 
			
		||||
  @Input() widget = false;
 | 
			
		||||
 | 
			
		||||
  miningWindowPreference: string;
 | 
			
		||||
@ -96,7 +96,7 @@ export class NodeStatisticsChartComponent implements OnInit {
 | 
			
		||||
      ],
 | 
			
		||||
      grid: {
 | 
			
		||||
        top: 30,
 | 
			
		||||
        bottom: 70,
 | 
			
		||||
        bottom: 20,
 | 
			
		||||
        right: this.right,
 | 
			
		||||
        left: this.left,
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
@ -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';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
<div class="container-xl" *ngIf="(node$ | async) as node">
 | 
			
		||||
  <h5 class="mb-0" style="color: #ffffff66" i18n="lightning.node">Lightning node</h5>
 | 
			
		||||
  <div class="title-container mb-2" *ngIf="!error">
 | 
			
		||||
    <h1 class="mb-0">{{ node.alias }}</h1>
 | 
			
		||||
    <span class="tx-link">
 | 
			
		||||
@ -42,24 +43,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>
 | 
			
		||||
@ -131,15 +118,26 @@
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <app-nodes-channels-map *ngIf="!error" [style]="'nodepage'" [publicKey]="node.public_key"></app-nodes-channels-map>
 | 
			
		||||
  <app-node-statistics-chart [publicKey]="node.public_key" *ngIf="!error"></app-node-statistics-chart>
 | 
			
		||||
  <div *ngIf="!error">
 | 
			
		||||
    <div class="row">
 | 
			
		||||
      <div class="col-sm">
 | 
			
		||||
        <app-nodes-channels-map [style]="'nodepage'" [publicKey]="node.public_key"></app-nodes-channels-map>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col-sm">
 | 
			
		||||
        <app-node-statistics-chart [publicKey]="node.public_key"></app-node-statistics-chart>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
  <div class="d-flex justify-content-between" *ngIf="!error">
 | 
			
		||||
    <h2>Channels ({{ channelsListStatus === 'open' ? node.opened_channel_count : node.closed_channel_count }})</h2>
 | 
			
		||||
    <h2 i18n="lightning.active-channels-map">Active channels map</h2>
 | 
			
		||||
    <app-node-channels style="display:block;margin-bottom: 40px" [publicKey]="node.public_key"></app-node-channels>
 | 
			
		||||
 | 
			
		||||
    <div class="d-flex justify-content-between">
 | 
			
		||||
      <h2>Channels ({{ channelsListStatus === 'open' ? node.opened_channel_count : node.closed_channel_count }})</h2>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <app-channels-list [publicKey]="node.public_key"
 | 
			
		||||
      (channelsStatusChangedEvent)="onChannelsListStatusChanged($event)"></app-channels-list>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <app-channels-list *ngIf="!error" [publicKey]="node.public_key"
 | 
			
		||||
    (channelsStatusChangedEvent)="onChannelsListStatusChanged($event)"></app-channels-list>
 | 
			
		||||
    
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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,21 @@
 | 
			
		||||
<div [class]="'full-container ' + style + (fitContainer ? ' fit-container' : '')">
 | 
			
		||||
<div [style]="style === 'widget' ? 'height: 250px' : ''">
 | 
			
		||||
  <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 *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 class="chart" [class]="style" 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 *ngIf="!chartOptions && style === 'nodepage'" style="padding-top: 30px"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div *ngIf="observable$ | async" class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
 | 
			
		||||
    (chartInit)="onChartInit($event)" (chartFinished)="onChartFinished($event)">
 | 
			
		||||
  <div class="text-center loading-spinner" [class]="style" *ngIf="isLoading">
 | 
			
		||||
    <div class="spinner-border text-light"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@ -10,25 +10,24 @@
 | 
			
		||||
.full-container {
 | 
			
		||||
  padding: 0px 15px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  min-height: 500px;
 | 
			
		||||
  min-height: 600px;
 | 
			
		||||
  height: calc(100% - 150px);
 | 
			
		||||
 | 
			
		||||
  @media (max-width: 992px) {
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    padding-bottom: 100px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full-container.nodepage {
 | 
			
		||||
  min-height: 400px;
 | 
			
		||||
  margin-top: 25px;
 | 
			
		||||
  margin-bottom: 25px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full-container.channelpage {
 | 
			
		||||
  min-height: 400px;
 | 
			
		||||
  margin-top: 25px;
 | 
			
		||||
  margin-bottom: 25px;
 | 
			
		||||
  min-height: 100%;
 | 
			
		||||
}
 | 
			
		||||
.full-container.widget {
 | 
			
		||||
  height: 250px;
 | 
			
		||||
  min-height: 250px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full-container.fit-container {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
@ -41,25 +40,6 @@
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.widget {
 | 
			
		||||
  width: 90vw;
 | 
			
		||||
  margin-left: auto;
 | 
			
		||||
  margin-right: auto;
 | 
			
		||||
  height: 250px;
 | 
			
		||||
  -webkit-mask: linear-gradient(0deg, #11131f00 5%, #11131fff 25%);
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    width: 100vw;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.widget > .chart {
 | 
			
		||||
  min-height: 250px;
 | 
			
		||||
  -webkit-mask: linear-gradient(180deg, #11131f00 0%, #11131fff 20%);
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    padding-bottom: 0px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.chart {
 | 
			
		||||
  min-height: 500px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
@ -79,4 +59,48 @@
 | 
			
		||||
  @media (max-width: 567px) {
 | 
			
		||||
    padding-bottom: 55px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
.chart.graph {
 | 
			
		||||
  min-height: 600px;
 | 
			
		||||
}
 | 
			
		||||
.chart.nodepage {
 | 
			
		||||
  min-height: 100%;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  padding-bottom: 0px;
 | 
			
		||||
}
 | 
			
		||||
.chart.channelpage {
 | 
			
		||||
  min-height: 400px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.widget {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  margin-left: auto;
 | 
			
		||||
  margin-right: auto;
 | 
			
		||||
  height: 250px;
 | 
			
		||||
  -webkit-mask: linear-gradient(0deg, #11131f00 5%, #11131fff 25%);
 | 
			
		||||
}
 | 
			
		||||
.widget > .chart {
 | 
			
		||||
  min-height: 250px;
 | 
			
		||||
  -webkit-mask: linear-gradient(180deg, #11131f00 0%, #11131fff 20%);
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    padding-bottom: 0px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.loading-spinner {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: calc(50% - 15px);
 | 
			
		||||
  z-index: 100;
 | 
			
		||||
}
 | 
			
		||||
.loading-spinner.widget {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 200px;
 | 
			
		||||
  z-index: 100;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    top: 250px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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,8 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
  channelOpacity = 0.1;
 | 
			
		||||
  channelColor = '#466d9d';
 | 
			
		||||
  channelCurve = 0;
 | 
			
		||||
  nodeSize = 4;
 | 
			
		||||
  isLoading = true;
 | 
			
		||||
 | 
			
		||||
  chartInstance = undefined;
 | 
			
		||||
  chartOptions: EChartsOption = {};
 | 
			
		||||
@ -49,8 +51,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,13 +65,17 @@ 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(
 | 
			
		||||
          this.assetsService.getWorldMapJson$,
 | 
			
		||||
          this.apiService.getChannelsGeo$(params.get('public_key') ?? undefined),
 | 
			
		||||
          this.style !== 'channelpage' ? this.apiService.getChannelsGeo$(params.get('public_key') ?? undefined, this.style) : [''],
 | 
			
		||||
          [params.get('public_key') ?? undefined]
 | 
			
		||||
        ).pipe(tap((data) => {
 | 
			
		||||
          registerMap('world', data[0]);
 | 
			
		||||
@ -90,10 +94,12 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          for (const channel of geoloc) {
 | 
			
		||||
            if (!thisNodeGPS && data[2] === channel[0]) {
 | 
			
		||||
              thisNodeGPS = [channel[2], channel[3]];
 | 
			
		||||
            } else if (!thisNodeGPS && data[2] === channel[4]) {
 | 
			
		||||
              thisNodeGPS = [channel[6], channel[7]];
 | 
			
		||||
            if (this.style === 'nodepage' && !thisNodeGPS) {
 | 
			
		||||
              if (data[2] === channel[0]) {
 | 
			
		||||
                thisNodeGPS = [channel[2], channel[3]];
 | 
			
		||||
              } else if (data[2] === channel[4]) {
 | 
			
		||||
                thisNodeGPS = [channel[6], channel[7]];
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 0 - node1 pubkey
 | 
			
		||||
@ -102,48 +108,68 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
            // 4 - node2 pubkey
 | 
			
		||||
            // 5 - node2 alias
 | 
			
		||||
            // 6,7 - node2 GPS
 | 
			
		||||
            const node1PubKey = 0;
 | 
			
		||||
            const node1Alias = 1;
 | 
			
		||||
            let node1GpsLat = 2;
 | 
			
		||||
            let node1GpsLgt = 3;
 | 
			
		||||
            const node2PubKey = 4;
 | 
			
		||||
            const node2Alias = 5;
 | 
			
		||||
            let node2GpsLat = 6;
 | 
			
		||||
            let node2GpsLgt = 7;
 | 
			
		||||
            let node1UniqueId = channel[node1PubKey];
 | 
			
		||||
            let node2UniqueId = channel[node2PubKey];
 | 
			
		||||
            if (this.style === 'widget') {
 | 
			
		||||
              node1GpsLat = 0;
 | 
			
		||||
              node1GpsLgt = 1;
 | 
			
		||||
              node2GpsLat = 2;
 | 
			
		||||
              node2GpsLgt = 3;
 | 
			
		||||
              node1UniqueId = channel[node1GpsLat].toString() + channel[node1GpsLgt].toString();
 | 
			
		||||
              node2UniqueId = channel[node2GpsLat].toString() + channel[node2GpsLgt].toString();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // We add a bit of noise so nodes at the same location are not all
 | 
			
		||||
            // on top of each other
 | 
			
		||||
            let random = Math.random() * 2 * Math.PI;
 | 
			
		||||
            let random2 = Math.random() * 0.01;
 | 
			
		||||
            
 | 
			
		||||
            if (!nodesPubkeys[channel[0]]) {
 | 
			
		||||
            if (!nodesPubkeys[node1UniqueId]) {
 | 
			
		||||
              nodes.push([
 | 
			
		||||
                channel[2] + random2 * Math.cos(random),
 | 
			
		||||
                channel[3] + random2 * Math.sin(random),
 | 
			
		||||
                channel[node1GpsLat] + random2 * Math.cos(random),
 | 
			
		||||
                channel[node1GpsLgt] + random2 * Math.sin(random),
 | 
			
		||||
                1,
 | 
			
		||||
                channel[0],
 | 
			
		||||
                channel[1]
 | 
			
		||||
                channel[node1PubKey],
 | 
			
		||||
                channel[node1Alias]
 | 
			
		||||
              ]);
 | 
			
		||||
              nodesPubkeys[channel[0]] = nodes[nodes.length - 1];
 | 
			
		||||
              nodesPubkeys[node1UniqueId] = nodes[nodes.length - 1];
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            random = Math.random() * 2 * Math.PI;
 | 
			
		||||
            random2 = Math.random() * 0.01;
 | 
			
		||||
 | 
			
		||||
            if (!nodesPubkeys[channel[4]]) {
 | 
			
		||||
            if (!nodesPubkeys[node2UniqueId]) {
 | 
			
		||||
              nodes.push([
 | 
			
		||||
                channel[6] + random2 * Math.cos(random),
 | 
			
		||||
                channel[7] + random2 * Math.sin(random),
 | 
			
		||||
                channel[node2GpsLat] + random2 * Math.cos(random),
 | 
			
		||||
                channel[node2GpsLgt] + random2 * Math.sin(random),
 | 
			
		||||
                1,
 | 
			
		||||
                channel[4],
 | 
			
		||||
                channel[5]
 | 
			
		||||
                channel[node2PubKey],
 | 
			
		||||
                channel[node2Alias]
 | 
			
		||||
              ]);
 | 
			
		||||
              nodesPubkeys[channel[4]] = nodes[nodes.length - 1];
 | 
			
		||||
              nodesPubkeys[node2UniqueId] = nodes[nodes.length - 1];
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const channelLoc = [];
 | 
			
		||||
            channelLoc.push(nodesPubkeys[channel[0]].slice(0, 2));            
 | 
			
		||||
            channelLoc.push(nodesPubkeys[channel[4]].slice(0, 2));
 | 
			
		||||
            channelLoc.push(nodesPubkeys[node1UniqueId].slice(0, 2));            
 | 
			
		||||
            channelLoc.push(nodesPubkeys[node2UniqueId].slice(0, 2));
 | 
			
		||||
            channelsLoc.push(channelLoc);
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (this.style === 'nodepage' && thisNodeGPS) {
 | 
			
		||||
            this.center = [thisNodeGPS[0], thisNodeGPS[1]];
 | 
			
		||||
            this.zoom = 10;
 | 
			
		||||
            this.zoom = 5;
 | 
			
		||||
            this.channelWidth = 1;
 | 
			
		||||
            this.channelOpacity = 1;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (this.style === 'channelpage' && this.channel.length > 0) {
 | 
			
		||||
            this.channelWidth = 2;
 | 
			
		||||
            this.channelOpacity = 1;
 | 
			
		||||
@ -170,15 +196,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 +233,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)',
 | 
			
		||||
@ -242,7 +261,7 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          large: false,
 | 
			
		||||
          progressive: 200,
 | 
			
		||||
          progressive: this.style === 'widget' ? 500 : 200,
 | 
			
		||||
          silent: true,
 | 
			
		||||
          type: 'lines',
 | 
			
		||||
          coordinateSystem: 'geo',
 | 
			
		||||
@ -270,6 +289,10 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    this.chartInstance = ec;
 | 
			
		||||
 | 
			
		||||
    this.chartInstance.on('finished', () => {
 | 
			
		||||
      this.isLoading = false;
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    if (this.style === 'widget') {
 | 
			
		||||
      this.chartInstance.getZr().on('click', (e) => {
 | 
			
		||||
        this.zone.run(() => {
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,2 @@
 | 
			
		||||
<div *ngIf="channelsObservable$ | async" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)">
 | 
			
		||||
</div>
 | 
			
		||||
@ -0,0 +1,138 @@
 | 
			
		||||
import { formatNumber } from '@angular/common';
 | 
			
		||||
import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnInit } from '@angular/core';
 | 
			
		||||
import { Router } from '@angular/router';
 | 
			
		||||
import { ECharts, EChartsOption, TreemapSeriesOption } from 'echarts';
 | 
			
		||||
import { Observable, tap } from 'rxjs';
 | 
			
		||||
import { lerpColor } from 'src/app/shared/graphs.utils';
 | 
			
		||||
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
 | 
			
		||||
import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
 | 
			
		||||
import { StateService } from '../../services/state.service';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-node-channels',
 | 
			
		||||
  templateUrl: './node-channels.component.html',
 | 
			
		||||
  styleUrls: ['./node-channels.component.scss'],
 | 
			
		||||
  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
			
		||||
})
 | 
			
		||||
export class NodeChannels implements OnChanges {
 | 
			
		||||
  @Input() publicKey: string;
 | 
			
		||||
 | 
			
		||||
  chartInstance: ECharts;
 | 
			
		||||
  chartOptions: EChartsOption = {};
 | 
			
		||||
  chartInitOptions = {
 | 
			
		||||
    renderer: 'svg',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  channelsObservable$: Observable<any>;
 | 
			
		||||
  isLoading: true;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(LOCALE_ID) public locale: string,
 | 
			
		||||
    private lightningApiService: LightningApiService,
 | 
			
		||||
    private amountShortenerPipe: AmountShortenerPipe,
 | 
			
		||||
    private zone: NgZone,
 | 
			
		||||
    private router: Router,
 | 
			
		||||
    private stateService: StateService,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  ngOnChanges(): void {
 | 
			
		||||
    this.prepareChartOptions(null);
 | 
			
		||||
 | 
			
		||||
    this.channelsObservable$ = this.lightningApiService.getChannelsByNodeId$(this.publicKey, -1, 'active')
 | 
			
		||||
      .pipe(
 | 
			
		||||
        tap((response) => {
 | 
			
		||||
          const biggestCapacity = response.body[0].capacity;
 | 
			
		||||
          this.prepareChartOptions(response.body.map(channel => {
 | 
			
		||||
            return {
 | 
			
		||||
              name: channel.node.alias,
 | 
			
		||||
              value: channel.capacity,
 | 
			
		||||
              shortId: channel.short_id,
 | 
			
		||||
              id: channel.id,
 | 
			
		||||
              itemStyle: {
 | 
			
		||||
                color: lerpColor('#1E88E5', '#D81B60', Math.pow(channel.capacity / biggestCapacity, 0.4)),
 | 
			
		||||
              }
 | 
			
		||||
            };
 | 
			
		||||
          }));
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  prepareChartOptions(data): void {
 | 
			
		||||
    this.chartOptions = {
 | 
			
		||||
      tooltip: {
 | 
			
		||||
        trigger: 'item',
 | 
			
		||||
        textStyle: {
 | 
			
		||||
          align: 'left',
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      series: <TreemapSeriesOption[]>[
 | 
			
		||||
        {
 | 
			
		||||
          left: 0,
 | 
			
		||||
          right: 0,
 | 
			
		||||
          bottom: 0,
 | 
			
		||||
          top: 0,
 | 
			
		||||
          roam: false,
 | 
			
		||||
          type: 'treemap',
 | 
			
		||||
          data: data,
 | 
			
		||||
          nodeClick: 'link',
 | 
			
		||||
          progressive: 100,
 | 
			
		||||
          tooltip: {
 | 
			
		||||
            show: true,
 | 
			
		||||
            backgroundColor: 'rgba(17, 19, 31, 1)',
 | 
			
		||||
            borderRadius: 4,
 | 
			
		||||
            shadowColor: 'rgba(0, 0, 0, 0.5)',
 | 
			
		||||
            textStyle: {
 | 
			
		||||
              color: '#b1b1b1',
 | 
			
		||||
            },
 | 
			
		||||
            borderColor: '#000',
 | 
			
		||||
            formatter: (value): string => {
 | 
			
		||||
              if (value.data.name === undefined) {
 | 
			
		||||
                return ``;
 | 
			
		||||
              }
 | 
			
		||||
              let capacity = '';
 | 
			
		||||
              if (value.data.value > 100000000) {
 | 
			
		||||
                capacity = formatNumber(Math.round(value.data.value / 100000000), this.locale, '1.2-2') + ' BTC';
 | 
			
		||||
              } else {
 | 
			
		||||
                capacity = <string>this.amountShortenerPipe.transform(value.data.value, 2) + ' sats';
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              return `
 | 
			
		||||
                <b style="color: white; margin-left: 2px">${value.data.shortId}</b><br>
 | 
			
		||||
                <span>Node: ${value.name}</span><br>
 | 
			
		||||
                <span>Capacity: ${capacity}</span>
 | 
			
		||||
              `;
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          itemStyle: {
 | 
			
		||||
            borderColor: 'black',
 | 
			
		||||
            borderWidth: 1,
 | 
			
		||||
          },
 | 
			
		||||
          breadcrumb: {
 | 
			
		||||
            show: false,
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    };    
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChartInit(ec: ECharts): void {
 | 
			
		||||
    if (this.chartInstance !== undefined) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.chartInstance = ec;
 | 
			
		||||
 | 
			
		||||
    this.chartInstance.on('click', (e) => {
 | 
			
		||||
      //@ts-ignore
 | 
			
		||||
      if (!e.data.id) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      this.zone.run(() => {
 | 
			
		||||
        //@ts-ignore
 | 
			
		||||
        const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/channel/${e.data.id}`);
 | 
			
		||||
        this.router.navigate([url]);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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;
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
<div [class]="!widget ? 'container-xl full-height' : ''">
 | 
			
		||||
  <h1 *ngIf="!widget" class="float-left" i18n="lightning.top-100-capacity">
 | 
			
		||||
    <span>Top 100 nodes by capacity</span>
 | 
			
		||||
  <h1 *ngIf="!widget" class="float-left">
 | 
			
		||||
    <span i18n="lightning.top-100-liquidity">Top 100 nodes liquidity ranking</span>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <div [class]="widget ? 'widget' : 'full'">
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { map, Observable } from 'rxjs';
 | 
			
		||||
import { INodesRanking, ITopNodesPerCapacity } from 'src/app/interfaces/node-api.interface';
 | 
			
		||||
import { isMobile } from 'src/app/shared/common.utils';
 | 
			
		||||
import { LightningApiService } from '../../lightning-api.service';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
@ -19,7 +20,7 @@ export class TopNodesPerCapacity implements OnInit {
 | 
			
		||||
  constructor(private apiService: LightningApiService) {}
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    for (let i = 1; i <= (this.widget ? 10 : 100); ++i) {
 | 
			
		||||
    for (let i = 1; i <= (this.widget ? (isMobile() ? 8 : 7) : 100); ++i) {
 | 
			
		||||
      this.skeletonRows.push(i);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -28,7 +29,7 @@ export class TopNodesPerCapacity implements OnInit {
 | 
			
		||||
    } else {
 | 
			
		||||
      this.topNodesPerCapacity$ = this.nodes$.pipe(
 | 
			
		||||
        map((ranking) => {
 | 
			
		||||
          return ranking.topByCapacity.slice(0, 10);
 | 
			
		||||
          return ranking.topByCapacity.slice(0, isMobile() ? 8 : 7);
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
<div [class]="!widget ? 'container-xl full-height' : ''">
 | 
			
		||||
  <h1 *ngIf="!widget" class="float-left" i18n="lightning.top-100-channel">
 | 
			
		||||
    <span>Top 100 nodes by channel count</span>
 | 
			
		||||
  <h1 *ngIf="!widget" class="float-left">
 | 
			
		||||
    <span i18n="lightning.top-100-connectivity">Top 100 nodes connectivity ranking</span>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <div [class]="widget ? 'widget' : 'full'">
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { map, Observable } from 'rxjs';
 | 
			
		||||
import { INodesRanking, ITopNodesPerChannels } from 'src/app/interfaces/node-api.interface';
 | 
			
		||||
import { isMobile } from 'src/app/shared/common.utils';
 | 
			
		||||
import { LightningApiService } from '../../lightning-api.service';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
@ -19,7 +20,7 @@ export class TopNodesPerChannels implements OnInit {
 | 
			
		||||
  constructor(private apiService: LightningApiService) {}
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    for (let i = 1; i <= (this.widget ? 10 : 100); ++i) {
 | 
			
		||||
    for (let i = 1; i <= (this.widget ? (isMobile() ? 8 : 7) : 100); ++i) {
 | 
			
		||||
      this.skeletonRows.push(i);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -28,7 +29,7 @@ export class TopNodesPerChannels implements OnInit {
 | 
			
		||||
    } else {
 | 
			
		||||
      this.topNodesPerChannels$ = this.nodes$.pipe(
 | 
			
		||||
        map((ranking) => {
 | 
			
		||||
          return ranking.topByChannels.slice(0, 10);
 | 
			
		||||
          return ranking.topByChannels.slice(0, isMobile() ? 8 : 7);
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -4,8 +4,8 @@
 | 
			
		||||
    <div class="col">
 | 
			
		||||
      <div class="card">
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
          <a class="title-link" href="" [routerLink]="['/lightning/nodes/top-capacity' | relativeUrl]">
 | 
			
		||||
            <h5 class="card-title d-inline" i18n="lightning.top-capacity-nodes">Top capacity nodes</h5>
 | 
			
		||||
          <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/liquidity' | relativeUrl]">
 | 
			
		||||
            <h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity ranking</h5>
 | 
			
		||||
            <span> </span>
 | 
			
		||||
            <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true"
 | 
			
		||||
              style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
 | 
			
		||||
@ -18,8 +18,8 @@
 | 
			
		||||
    <div class="col">
 | 
			
		||||
      <div class="card">
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
          <a class="title-link" href="" [routerLink]="['/lightning/nodes/top-channels' | relativeUrl]">
 | 
			
		||||
            <h5 class="card-title d-inline" i18n="lightning.top-channels-nodes">Most connected nodes</h5>
 | 
			
		||||
          <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/connectivity' | relativeUrl]">
 | 
			
		||||
            <h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity ranking</h5>
 | 
			
		||||
            <span> </span>
 | 
			
		||||
            <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true"
 | 
			
		||||
              style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
 | 
			
		||||
@ -271,10 +271,11 @@ export class ApiService {
 | 
			
		||||
    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getChannelsGeo$(publicKey?: string): Observable<any> {
 | 
			
		||||
  getChannelsGeo$(publicKey?: string, style?: 'graph' | 'nodepage' | 'widget' | 'channelpage'): Observable<any> {
 | 
			
		||||
    return this.httpClient.get<any[]>(
 | 
			
		||||
      this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels-geo' +
 | 
			
		||||
        (publicKey !== undefined ? `/${publicKey}` : '')
 | 
			
		||||
        (publicKey !== undefined ? `/${publicKey}`   : '') +
 | 
			
		||||
        (style     !== undefined ? `?style=${style}` : '')
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -153,7 +153,7 @@ export class StateService {
 | 
			
		||||
    if (this.env.BASE_MODULE !== 'mempool' && this.env.BASE_MODULE !== 'liquid') {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const networkMatches = url.match(/\/(bisq|testnet|liquidtestnet|liquid|signet)/);
 | 
			
		||||
    const networkMatches = url.match(/^\/(bisq|testnet|liquidtestnet|liquid|signet)/);
 | 
			
		||||
    switch (networkMatches && networkMatches[1]) {
 | 
			
		||||
      case 'liquid':
 | 
			
		||||
        if (this.network !== 'liquid') {
 | 
			
		||||
 | 
			
		||||
@ -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,25 @@ 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);
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * https://gist.githubusercontent.com/rosszurowski/67f04465c424a9bc0dae/raw/90ee06c5aa84ab352eb5b233d0a8263c3d8708e5/lerp-color.js
 | 
			
		||||
 * A linear interpolator for hexadecimal colors
 | 
			
		||||
 * @param {String} a
 | 
			
		||||
 * @param {String} b
 | 
			
		||||
 * @param {Number} amount
 | 
			
		||||
 * @example
 | 
			
		||||
 * // returns #7F7F7F
 | 
			
		||||
 * lerpColor('#000000', '#ffffff', 0.5)
 | 
			
		||||
 * @returns {String}
 | 
			
		||||
 */
 | 
			
		||||
export function lerpColor(a: string, b: string, amount: number): string {
 | 
			
		||||
  const ah = parseInt(a.replace(/#/g, ''), 16),
 | 
			
		||||
    ar = ah >> 16, ag = ah >> 8 & 0xff, ab = ah & 0xff,
 | 
			
		||||
    bh = parseInt(b.replace(/#/g, ''), 16),
 | 
			
		||||
    br = bh >> 16, bg = bh >> 8 & 0xff, bb = bh & 0xff,
 | 
			
		||||
    rr = ar + amount * (br - ar),
 | 
			
		||||
    rg = ag + amount * (bg - ag),
 | 
			
		||||
    rb = ab + amount * (bb - ab);
 | 
			
		||||
 | 
			
		||||
  return '#' + ((1 << 24) + (rr << 16) + (rg << 8) + rb | 0).toString(16).slice(1);
 | 
			
		||||
}
 | 
			
		||||
@ -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 {
 | 
			
		||||
 | 
			
		||||
@ -77,6 +77,8 @@ do for url in / \
 | 
			
		||||
	'/api/v1/mining/difficulty-adjustments/2y' \
 | 
			
		||||
	'/api/v1/mining/difficulty-adjustments/3y' \
 | 
			
		||||
	'/api/v1/mining/difficulty-adjustments/all' \
 | 
			
		||||
	'/api/v1/lightning/channels-geo?style=widget' \
 | 
			
		||||
	'/api/v1/lightning/channels-geo?style=graph' \
 | 
			
		||||
 | 
			
		||||
	do
 | 
			
		||||
		curl -s "https://${hostname}${url}" >/dev/null
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user