Merge branch 'master' into translations_frontend-src-locale-messages-xlf--master_pl
This commit is contained in:
		
						commit
						c509a69f1d
					
				
							
								
								
									
										87
									
								
								.github/workflows/cypress.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										87
									
								
								.github/workflows/cypress.yml
									
									
									
									
										vendored
									
									
								
							@ -6,86 +6,53 @@ on:
 | 
				
			|||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  cypress:
 | 
					  cypress:
 | 
				
			||||||
    if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
 | 
					    if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
 | 
				
			||||||
    runs-on: ${{ matrix.os }}
 | 
					    runs-on: "ubuntu-latest"
 | 
				
			||||||
    strategy:
 | 
					    strategy:
 | 
				
			||||||
      fail-fast: false
 | 
					      fail-fast: false
 | 
				
			||||||
      matrix:
 | 
					      matrix:
 | 
				
			||||||
        containers: [1, 2, 3, 4, 5]
 | 
					        module: ["mempool", "liquid", "bisq"]
 | 
				
			||||||
        os: ["ubuntu-latest"]
 | 
					        include:
 | 
				
			||||||
        browser: [chrome]
 | 
					          - module: "mempool"
 | 
				
			||||||
    name: E2E tests on ${{ matrix.browser }} - ${{ matrix.os }}
 | 
					            spec: |
 | 
				
			||||||
 | 
					              cypress/e2e/mainnet/*.spec.ts
 | 
				
			||||||
 | 
					              cypress/e2e/signet/*.spec.ts
 | 
				
			||||||
 | 
					              cypress/e2e/testnet/*.spec.ts
 | 
				
			||||||
 | 
					          - module: "liquid"
 | 
				
			||||||
 | 
					            spec: |
 | 
				
			||||||
 | 
					              cypress/e2e/liquid/liquid.spec.ts
 | 
				
			||||||
 | 
					              cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
 | 
				
			||||||
 | 
					          - module: "bisq"
 | 
				
			||||||
 | 
					            spec: |
 | 
				
			||||||
 | 
					              cypress/e2e/bisq/bisq.spec.ts
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					    name: E2E tests for ${{ matrix.module }}
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - name: Checkout
 | 
					      - name: Checkout
 | 
				
			||||||
        uses: actions/checkout@v2
 | 
					        uses: actions/checkout@v2
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          path: ${{ matrix.module }}
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
      - name: Setup node
 | 
					      - name: Setup node
 | 
				
			||||||
        uses: actions/setup-node@v2
 | 
					        uses: actions/setup-node@v2
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          node-version: 16.15.0
 | 
					          node-version: 16.15.0
 | 
				
			||||||
          cache: 'npm'
 | 
					          cache: 'npm'
 | 
				
			||||||
          cache-dependency-path: frontend/package-lock.json
 | 
					          cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
 | 
				
			||||||
      - name: ${{ matrix.browser }} browser tests (Mempool)
 | 
					 | 
				
			||||||
        uses: cypress-io/github-action@v4
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          tag: ${{ github.event_name }}
 | 
					 | 
				
			||||||
          working-directory: frontend
 | 
					 | 
				
			||||||
          build: npm run config:defaults:mempool
 | 
					 | 
				
			||||||
          start: npm run start:local-staging
 | 
					 | 
				
			||||||
          wait-on: 'http://localhost:4200'
 | 
					 | 
				
			||||||
          wait-on-timeout: 120
 | 
					 | 
				
			||||||
          record: true
 | 
					 | 
				
			||||||
          parallel: true
 | 
					 | 
				
			||||||
          spec: |
 | 
					 | 
				
			||||||
            cypress/e2e/mainnet/*.spec.ts
 | 
					 | 
				
			||||||
            cypress/e2e/signet/*.spec.ts
 | 
					 | 
				
			||||||
            cypress/e2e/testnet/*.spec.ts
 | 
					 | 
				
			||||||
          group: Tests on ${{ matrix.browser }} (Mempool)
 | 
					 | 
				
			||||||
          browser: ${{ matrix.browser }}
 | 
					 | 
				
			||||||
          ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
 | 
					 | 
				
			||||||
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
 | 
					 | 
				
			||||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
          CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      - name: ${{ matrix.browser }} browser tests (Liquid)
 | 
					      - name: Chrome browser tests (${{ matrix.module }})
 | 
				
			||||||
        uses: cypress-io/github-action@v4
 | 
					        uses: cypress-io/github-action@v4
 | 
				
			||||||
        if: always()
 | 
					 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          tag: ${{ github.event_name }}
 | 
					          tag: ${{ github.event_name }}
 | 
				
			||||||
          working-directory: frontend
 | 
					          working-directory: ${{ matrix.module }}/frontend
 | 
				
			||||||
          build: npm run config:defaults:liquid
 | 
					          build: npm run config:defaults:${{ matrix.module }}
 | 
				
			||||||
          start: npm run start:local-staging
 | 
					          start: npm run start:local-staging
 | 
				
			||||||
          wait-on: 'http://localhost:4200'
 | 
					          wait-on: 'http://localhost:4200'
 | 
				
			||||||
          wait-on-timeout: 120
 | 
					          wait-on-timeout: 120
 | 
				
			||||||
          record: true
 | 
					          record: true
 | 
				
			||||||
          parallel: true
 | 
					          parallel: true
 | 
				
			||||||
          spec: |
 | 
					          spec: ${{ matrix.spec }}
 | 
				
			||||||
            cypress/e2e/liquid/liquid.spec.ts
 | 
					          group: Tests on Chrome (${{ matrix.module }})
 | 
				
			||||||
            cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
 | 
					          browser: "chrome"
 | 
				
			||||||
          group: Tests on ${{ matrix.browser }} (Liquid)
 | 
					 | 
				
			||||||
          browser: ${{ matrix.browser }}
 | 
					 | 
				
			||||||
          ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
 | 
					 | 
				
			||||||
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
 | 
					 | 
				
			||||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
          CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      - name: ${{ matrix.browser }} browser tests (Bisq)
 | 
					 | 
				
			||||||
        uses: cypress-io/github-action@v4
 | 
					 | 
				
			||||||
        if: always()
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          tag: ${{ github.event_name }}
 | 
					 | 
				
			||||||
          working-directory: frontend
 | 
					 | 
				
			||||||
          build: npm run config:defaults:bisq
 | 
					 | 
				
			||||||
          start: npm run start:local-staging
 | 
					 | 
				
			||||||
          wait-on: 'http://localhost:4200'
 | 
					 | 
				
			||||||
          wait-on-timeout: 120
 | 
					 | 
				
			||||||
          record: true
 | 
					 | 
				
			||||||
          parallel: true
 | 
					 | 
				
			||||||
          spec: cypress/e2e/bisq/bisq.spec.ts
 | 
					 | 
				
			||||||
          group: Tests on ${{ matrix.browser }} (Bisq)
 | 
					 | 
				
			||||||
          browser: ${{ matrix.browser }}
 | 
					 | 
				
			||||||
          ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
 | 
					          ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
 | 
					          COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										4
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							@ -1,9 +1,9 @@
 | 
				
			|||||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
 | 
					# See http://help.github.com/ignore-files/ for more about ignoring files.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# production config and external assets
 | 
					# production config and external assets
 | 
				
			||||||
*.json
 | 
					 | 
				
			||||||
!mempool-config.sample.json
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					mempool-config.json
 | 
				
			||||||
 | 
					pools.json
 | 
				
			||||||
icons.json
 | 
					icons.json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# compiled output
 | 
					# compiled output
 | 
				
			||||||
 | 
				
			|||||||
@ -578,7 +578,7 @@ class Blocks {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    // Index the response if needed
 | 
					    // Index the response if needed
 | 
				
			||||||
    if (Common.blocksSummariesIndexingEnabled() === true) {
 | 
					    if (Common.blocksSummariesIndexingEnabled() === true) {
 | 
				
			||||||
      await BlocksSummariesRepository.$saveSummary(block.height, summary);
 | 
					      await BlocksSummariesRepository.$saveSummary({height: block.height, mined: summary});
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return summary.transactions;
 | 
					    return summary.transactions;
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,7 @@ import logger from '../logger';
 | 
				
			|||||||
import { Common } from './common';
 | 
					import { Common } from './common';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class DatabaseMigration {
 | 
					class DatabaseMigration {
 | 
				
			||||||
  private static currentVersion = 31;
 | 
					  private static currentVersion = 33;
 | 
				
			||||||
  private queryTimeout = 120000;
 | 
					  private queryTimeout = 120000;
 | 
				
			||||||
  private statisticsAddedIndexed = false;
 | 
					  private statisticsAddedIndexed = false;
 | 
				
			||||||
  private uniqueLogs: string[] = [];
 | 
					  private uniqueLogs: string[] = [];
 | 
				
			||||||
@ -297,7 +297,14 @@ class DatabaseMigration {
 | 
				
			|||||||
      await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE');
 | 
					      await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE');
 | 
				
			||||||
      await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`');
 | 
					      await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`');
 | 
				
			||||||
      await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices'));
 | 
					      await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices'));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (databaseSchemaVersion < 32 && isBitcoin == true) {
 | 
				
			||||||
 | 
					      await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD `template` JSON DEFAULT "[]"');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (databaseSchemaVersion < 33 && isBitcoin == true) {
 | 
				
			||||||
 | 
					      await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization", "country_iso_code") NOT NULL');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -13,6 +13,30 @@ class ChannelsApi {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getAllChannelsGeo(): Promise<any[]> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const 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
 | 
				
			||||||
 | 
					      `;
 | 
				
			||||||
 | 
					      const [rows]: any = await DB.query(query);
 | 
				
			||||||
 | 
					      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]);
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err('$getAllChannelsGeo error: ' + (e instanceof Error ? e.message : e));
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async $searchChannelsById(search: string): Promise<any[]> {
 | 
					  public async $searchChannelsById(search: string): Promise<any[]> {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const searchStripped = search.replace('%', '') + '%';
 | 
					      const searchStripped = search.replace('%', '') + '%';
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,7 @@ class ChannelsRoutes {
 | 
				
			|||||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/search/:search', this.$searchChannelsById)
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/search/:search', this.$searchChannelsById)
 | 
				
			||||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/:short_id', this.$getChannel)
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/:short_id', this.$getChannel)
 | 
				
			||||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels', this.$getChannelsForNode)
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels', this.$getChannelsForNode)
 | 
				
			||||||
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels-geo', this.$getChannelsGeo)
 | 
				
			||||||
    ;
 | 
					    ;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -93,6 +94,15 @@ class ChannelsRoutes {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async $getChannelsGeo(req: Request, res: Response) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const channels = await channelsApi.$getAllChannelsGeo();
 | 
				
			||||||
 | 
					      res.json(channels);
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      res.status(500).send(e instanceof Error ? e.message : e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default new ChannelsRoutes();
 | 
					export default new ChannelsRoutes();
 | 
				
			||||||
 | 
				
			|||||||
@ -93,6 +93,132 @@ class NodesApi {
 | 
				
			|||||||
      throw e;
 | 
					      throw e;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getNodesISP() {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      let query = `SELECT nodes.as_number as ispId, geo_names.names as names, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity
 | 
				
			||||||
 | 
					        FROM nodes
 | 
				
			||||||
 | 
					        JOIN geo_names ON geo_names.id = nodes.as_number
 | 
				
			||||||
 | 
					        JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key
 | 
				
			||||||
 | 
					        GROUP BY as_number
 | 
				
			||||||
 | 
					        ORDER BY COUNT(DISTINCT nodes.public_key) DESC
 | 
				
			||||||
 | 
					      `;
 | 
				
			||||||
 | 
					      const [nodesCountPerAS]: any = await DB.query(query);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      query = `SELECT COUNT(*) as total FROM nodes WHERE as_number IS NOT NULL`;
 | 
				
			||||||
 | 
					      const [nodesWithAS]: any = await DB.query(query);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const nodesPerAs: any[] = [];
 | 
				
			||||||
 | 
					      for (const as of nodesCountPerAS) {
 | 
				
			||||||
 | 
					        nodesPerAs.push({
 | 
				
			||||||
 | 
					          ispId: as.ispId,
 | 
				
			||||||
 | 
					          name: JSON.parse(as.names),
 | 
				
			||||||
 | 
					          count: as.nodesCount,
 | 
				
			||||||
 | 
					          share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100,
 | 
				
			||||||
 | 
					          capacity: as.capacity,
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return nodesPerAs;
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`);
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getNodesPerCountry(countryId: string) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const query = `
 | 
				
			||||||
 | 
					        SELECT node_stats.public_key, node_stats.capacity, node_stats.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
 | 
				
			||||||
 | 
					        FROM node_stats
 | 
				
			||||||
 | 
					        JOIN (
 | 
				
			||||||
 | 
					          SELECT public_key, MAX(added) as last_added
 | 
				
			||||||
 | 
					          FROM node_stats
 | 
				
			||||||
 | 
					          GROUP BY public_key
 | 
				
			||||||
 | 
					        ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added
 | 
				
			||||||
 | 
					        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_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
 | 
				
			||||||
 | 
					        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].city = JSON.parse(rows[i].city);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return rows;
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err(`Cannot get nodes for country id ${countryId}. Reason: ${e instanceof Error ? e.message : e}`);
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getNodesPerISP(ISPId: string) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const query = `
 | 
				
			||||||
 | 
					        SELECT node_stats.public_key, node_stats.capacity, node_stats.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
 | 
				
			||||||
 | 
					        FROM node_stats
 | 
				
			||||||
 | 
					        JOIN (
 | 
				
			||||||
 | 
					          SELECT public_key, MAX(added) as last_added
 | 
				
			||||||
 | 
					          FROM node_stats
 | 
				
			||||||
 | 
					          GROUP BY public_key
 | 
				
			||||||
 | 
					        ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added
 | 
				
			||||||
 | 
					        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_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
 | 
				
			||||||
 | 
					        WHERE nodes.as_number = ?
 | 
				
			||||||
 | 
					        ORDER BY capacity DESC
 | 
				
			||||||
 | 
					      `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const [rows]: any = await DB.query(query, [ISPId]);
 | 
				
			||||||
 | 
					      for (let i = 0; i < rows.length; ++i) {
 | 
				
			||||||
 | 
					        rows[i].country = JSON.parse(rows[i].country);
 | 
				
			||||||
 | 
					        rows[i].city = JSON.parse(rows[i].city);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return rows;
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err(`Cannot get nodes for ISP id ${ISPId}. Reason: ${e instanceof Error ? e.message : e}`);
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getNodesCountries() {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      let query = `SELECT geo_names.names as names, geo_names_iso.names as iso_code, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity
 | 
				
			||||||
 | 
					        FROM nodes
 | 
				
			||||||
 | 
					        JOIN geo_names ON geo_names.id = nodes.country_id AND geo_names.type = 'country'
 | 
				
			||||||
 | 
					        JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
 | 
				
			||||||
 | 
					        JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key
 | 
				
			||||||
 | 
					        GROUP BY country_id
 | 
				
			||||||
 | 
					        ORDER BY COUNT(DISTINCT nodes.public_key) DESC
 | 
				
			||||||
 | 
					      `;
 | 
				
			||||||
 | 
					      const [nodesCountPerCountry]: any = await DB.query(query);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      query = `SELECT COUNT(*) as total FROM nodes WHERE country_id IS NOT NULL`;
 | 
				
			||||||
 | 
					      const [nodesWithAS]: any = await DB.query(query);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const nodesPerCountry: any[] = [];
 | 
				
			||||||
 | 
					      for (const country of nodesCountPerCountry) {
 | 
				
			||||||
 | 
					        nodesPerCountry.push({
 | 
				
			||||||
 | 
					          name: JSON.parse(country.names),
 | 
				
			||||||
 | 
					          iso: country.iso_code, 
 | 
				
			||||||
 | 
					          count: country.nodesCount,
 | 
				
			||||||
 | 
					          share: Math.floor(country.nodesCount / nodesWithAS[0].total * 10000) / 100,
 | 
				
			||||||
 | 
					          capacity: country.capacity,
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return nodesPerCountry;
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`);
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default new NodesApi();
 | 
					export default new NodesApi();
 | 
				
			||||||
 | 
				
			|||||||
@ -1,13 +1,19 @@
 | 
				
			|||||||
import config from '../../config';
 | 
					import config from '../../config';
 | 
				
			||||||
import { Application, Request, Response } from 'express';
 | 
					import { Application, Request, Response } from 'express';
 | 
				
			||||||
import nodesApi from './nodes.api';
 | 
					import nodesApi from './nodes.api';
 | 
				
			||||||
 | 
					import DB from '../../database';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NodesRoutes {
 | 
					class NodesRoutes {
 | 
				
			||||||
  constructor() { }
 | 
					  constructor() { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public initRoutes(app: Application) {
 | 
					  public initRoutes(app: Application) {
 | 
				
			||||||
    app
 | 
					    app
 | 
				
			||||||
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry)
 | 
				
			||||||
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP)
 | 
				
			||||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode)
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode)
 | 
				
			||||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes)
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes)
 | 
				
			||||||
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp', this.$getNodesISP)
 | 
				
			||||||
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/countries', this.$getNodesCountries)
 | 
				
			||||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
 | 
					      .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)
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)
 | 
				
			||||||
    ;
 | 
					    ;
 | 
				
			||||||
@ -56,6 +62,85 @@ class NodesRoutes {
 | 
				
			|||||||
      res.status(500).send(e instanceof Error ? e.message : e);
 | 
					      res.status(500).send(e instanceof Error ? e.message : e);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async $getNodesISP(req: Request, res: Response) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const nodesPerAs = await nodesApi.$getNodesISP();
 | 
				
			||||||
 | 
					      res.header('Pragma', 'public');
 | 
				
			||||||
 | 
					      res.header('Cache-control', 'public');
 | 
				
			||||||
 | 
					      res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
 | 
				
			||||||
 | 
					      res.json(nodesPerAs);
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      res.status(500).send(e instanceof Error ? e.message : e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async $getNodesPerCountry(req: Request, res: Response) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const [country]: any[] = await DB.query(
 | 
				
			||||||
 | 
					        `SELECT geo_names.id, geo_names_country.names as country_names
 | 
				
			||||||
 | 
					        FROM geo_names
 | 
				
			||||||
 | 
					        JOIN geo_names geo_names_country on geo_names.id = geo_names_country.id AND geo_names_country.type = 'country'
 | 
				
			||||||
 | 
					        WHERE geo_names.type = 'country_iso_code' AND geo_names.names = ?`,
 | 
				
			||||||
 | 
					        [req.params.country]
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (country.length === 0) {
 | 
				
			||||||
 | 
					        res.status(404).send(`This country does not exist or does not host any lightning nodes on clearnet`);
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const nodes = await nodesApi.$getNodesPerCountry(country[0].id);
 | 
				
			||||||
 | 
					      res.header('Pragma', 'public');
 | 
				
			||||||
 | 
					      res.header('Cache-control', 'public');
 | 
				
			||||||
 | 
					      res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
 | 
				
			||||||
 | 
					      res.json({
 | 
				
			||||||
 | 
					        country: JSON.parse(country[0].country_names),
 | 
				
			||||||
 | 
					        nodes: nodes,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      res.status(500).send(e instanceof Error ? e.message : e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async $getNodesPerISP(req: Request, res: Response) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const [isp]: any[] = await DB.query(
 | 
				
			||||||
 | 
					        `SELECT geo_names.names as isp_name
 | 
				
			||||||
 | 
					        FROM geo_names
 | 
				
			||||||
 | 
					        WHERE geo_names.type = 'as_organization' AND geo_names.id = ?`,
 | 
				
			||||||
 | 
					        [req.params.isp]
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (isp.length === 0) {
 | 
				
			||||||
 | 
					        res.status(404).send(`This ISP does not exist or does not host any lightning nodes on clearnet`);
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const nodes = await nodesApi.$getNodesPerISP(req.params.isp);
 | 
				
			||||||
 | 
					      res.header('Pragma', 'public');
 | 
				
			||||||
 | 
					      res.header('Cache-control', 'public');
 | 
				
			||||||
 | 
					      res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
 | 
				
			||||||
 | 
					      res.json({
 | 
				
			||||||
 | 
					        isp: JSON.parse(isp[0].isp_name),
 | 
				
			||||||
 | 
					        nodes: nodes,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      res.status(500).send(e instanceof Error ? e.message : e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async $getNodesCountries(req: Request, res: Response) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const nodesPerAs = await nodesApi.$getNodesCountries();
 | 
				
			||||||
 | 
					      res.header('Pragma', 'public');
 | 
				
			||||||
 | 
					      res.header('Cache-control', 'public');
 | 
				
			||||||
 | 
					      res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
 | 
				
			||||||
 | 
					      res.json(nodesPerAs);
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      res.status(500).send(e instanceof Error ? e.message : e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default new NodesRoutes();
 | 
					export default new NodesRoutes();
 | 
				
			||||||
 | 
				
			|||||||
@ -26,6 +26,7 @@ class MiningRoutes {
 | 
				
			|||||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
 | 
				
			||||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments)
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments)
 | 
				
			||||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
 | 
				
			||||||
 | 
					      .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit)
 | 
				
			||||||
    ;
 | 
					    ;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -233,6 +234,18 @@ class MiningRoutes {
 | 
				
			|||||||
      res.status(500).send(e instanceof Error ? e.message : e);
 | 
					      res.status(500).send(e instanceof Error ? e.message : e);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getBlockAudit(req: Request, res: Response) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash);
 | 
				
			||||||
 | 
					      res.header('Pragma', 'public');
 | 
				
			||||||
 | 
					      res.header('Cache-control', 'public');
 | 
				
			||||||
 | 
					      res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
 | 
				
			||||||
 | 
					      res.json(audit);
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      res.status(500).send(e instanceof Error ? e.message : e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default new MiningRoutes();
 | 
					export default new MiningRoutes();
 | 
				
			||||||
 | 
				
			|||||||
@ -17,6 +17,7 @@ import rbfCache from './rbf-cache';
 | 
				
			|||||||
import difficultyAdjustment from './difficulty-adjustment';
 | 
					import difficultyAdjustment from './difficulty-adjustment';
 | 
				
			||||||
import feeApi from './fee-api';
 | 
					import feeApi from './fee-api';
 | 
				
			||||||
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
 | 
					import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
 | 
				
			||||||
 | 
					import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class WebsocketHandler {
 | 
					class WebsocketHandler {
 | 
				
			||||||
  private wss: WebSocket.Server | undefined;
 | 
					  private wss: WebSocket.Server | undefined;
 | 
				
			||||||
@ -442,6 +443,22 @@ class WebsocketHandler {
 | 
				
			|||||||
      mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
 | 
					      mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (Common.indexingEnabled()) {
 | 
					      if (Common.indexingEnabled()) {
 | 
				
			||||||
 | 
					        const stripped = _mempoolBlocks[0].transactions.map((tx) => {
 | 
				
			||||||
 | 
					          return {
 | 
				
			||||||
 | 
					            txid: tx.txid,
 | 
				
			||||||
 | 
					            vsize: tx.vsize,
 | 
				
			||||||
 | 
					            fee: tx.fee ? Math.round(tx.fee) : 0,
 | 
				
			||||||
 | 
					            value: tx.value,
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					        });  
 | 
				
			||||||
 | 
					        BlocksSummariesRepository.$saveSummary({
 | 
				
			||||||
 | 
					          height: block.height,
 | 
				
			||||||
 | 
					          template: {
 | 
				
			||||||
 | 
					            id: block.id,
 | 
				
			||||||
 | 
					            transactions: stripped
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        BlocksAuditsRepository.$saveAudit({
 | 
					        BlocksAuditsRepository.$saveAudit({
 | 
				
			||||||
          time: block.timestamp,
 | 
					          time: block.timestamp,
 | 
				
			||||||
          height: block.height,
 | 
					          height: block.height,
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import transactionUtils from '../api/transaction-utils';
 | 
				
			||||||
import DB from '../database';
 | 
					import DB from '../database';
 | 
				
			||||||
import logger from '../logger';
 | 
					import logger from '../logger';
 | 
				
			||||||
import { BlockAudit } from '../mempool.interfaces';
 | 
					import { BlockAudit } from '../mempool.interfaces';
 | 
				
			||||||
@ -45,6 +46,30 @@ class BlocksAuditRepositories {
 | 
				
			|||||||
      throw e;
 | 
					      throw e;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getBlockAudit(hash: string): Promise<any> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const [rows]: any[] = await DB.query(
 | 
				
			||||||
 | 
					        `SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
 | 
				
			||||||
 | 
					        blocks.weight, blocks.tx_count,
 | 
				
			||||||
 | 
					        transactions, template, missing_txs as missingTxs, added_txs as addedTxs, match_rate as matchRate
 | 
				
			||||||
 | 
					        FROM blocks_audits
 | 
				
			||||||
 | 
					        JOIN blocks ON blocks.hash = blocks_audits.hash
 | 
				
			||||||
 | 
					        JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
 | 
				
			||||||
 | 
					        WHERE blocks_audits.hash = "${hash}"
 | 
				
			||||||
 | 
					      `);
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
 | 
				
			||||||
 | 
					      rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
 | 
				
			||||||
 | 
					      rows[0].transactions = JSON.parse(rows[0].transactions);
 | 
				
			||||||
 | 
					      rows[0].template = JSON.parse(rows[0].template);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					      return rows[0];
 | 
				
			||||||
 | 
					    } catch (e: any) {
 | 
				
			||||||
 | 
					      logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default new BlocksAuditRepositories();
 | 
					export default new BlocksAuditRepositories();
 | 
				
			||||||
 | 
				
			|||||||
@ -17,14 +17,24 @@ class BlocksSummariesRepository {
 | 
				
			|||||||
    return undefined;
 | 
					    return undefined;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async $saveSummary(height: number, summary: BlockSummary) {
 | 
					  public async $saveSummary(params: { height: number, mined?: BlockSummary, template?: BlockSummary}) {
 | 
				
			||||||
 | 
					    const blockId = params.mined?.id ?? params.template?.id;
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?)`, [height, summary.id, JSON.stringify(summary.transactions)]);
 | 
					      const [dbSummary]: any[] = await DB.query(`SELECT * FROM blocks_summaries WHERE id = "${blockId}"`);
 | 
				
			||||||
 | 
					      if (dbSummary.length === 0) { // First insertion
 | 
				
			||||||
 | 
					        await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?, ?)`, [
 | 
				
			||||||
 | 
					          params.height, blockId, JSON.stringify(params.mined?.transactions ?? []), JSON.stringify(params.template?.transactions ?? [])
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					      } else if (params.mined !== undefined) { // Update mined block summary
 | 
				
			||||||
 | 
					        await DB.query(`UPDATE blocks_summaries SET transactions = ? WHERE id = "${params.mined.id}"`, [JSON.stringify(params.mined.transactions)]);
 | 
				
			||||||
 | 
					      } else if (params.template !== undefined) { // Update template block summary
 | 
				
			||||||
 | 
					        await DB.query(`UPDATE blocks_summaries SET template = ? WHERE id = "${params.template.id}"`, [JSON.stringify(params.template?.transactions)]);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    } catch (e: any) {
 | 
					    } catch (e: any) {
 | 
				
			||||||
      if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
 | 
					      if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
 | 
				
			||||||
        logger.debug(`Cannot save block summary for ${summary.id} because it has already been indexed, ignoring`);
 | 
					        logger.debug(`Cannot save block summary for ${blockId} because it has already been indexed, ignoring`);
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        logger.debug(`Cannot save block summary for ${summary.id}. Reason: ${e instanceof Error ? e.message : e}`);
 | 
					        logger.debug(`Cannot save block summary for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
 | 
				
			||||||
        throw e;
 | 
					        throw e;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -39,6 +39,13 @@ export async function $lookupNodeLocation(): Promise<void> {
 | 
				
			|||||||
                [city.country?.geoname_id, JSON.stringify(city.country?.names)]);
 | 
					                [city.country?.geoname_id, JSON.stringify(city.country?.names)]);
 | 
				
			||||||
             }
 | 
					             }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Store Country ISO code
 | 
				
			||||||
 | 
					            if (city.country?.iso_code) {
 | 
				
			||||||
 | 
					              await DB.query(
 | 
				
			||||||
 | 
					               `INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'country_iso_code', ?)`,
 | 
				
			||||||
 | 
					               [city.country?.geoname_id, city.country?.iso_code]);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Store Division
 | 
					            // Store Division
 | 
				
			||||||
            if (city.subdivisions && city.subdivisions[0]) {
 | 
					            if (city.subdivisions && city.subdivisions[0]) {
 | 
				
			||||||
              await DB.query(
 | 
					              await DB.query(
 | 
				
			||||||
 | 
				
			|||||||
@ -62,7 +62,7 @@ class KrakenApi implements PriceFeed {
 | 
				
			|||||||
    // CHF weekly price history goes back to timestamp 1575504000 (December 5, 2019)
 | 
					    // CHF weekly price history goes back to timestamp 1575504000 (December 5, 2019)
 | 
				
			||||||
    // AUD weekly price history goes back to timestamp 1591833600 (June 11, 2020)
 | 
					    // AUD weekly price history goes back to timestamp 1591833600 (June 11, 2020)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const priceHistory: any = {}; // map: timestamp -> Prices
 | 
					    let priceHistory: any = {}; // map: timestamp -> Prices
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (const currency of this.currencies) {
 | 
					    for (const currency of this.currencies) {
 | 
				
			||||||
      const response = await query(this.urlHist.replace('{GRANULARITY}', '10080') + currency);
 | 
					      const response = await query(this.urlHist.replace('{GRANULARITY}', '10080') + currency);
 | 
				
			||||||
@ -83,6 +83,10 @@ class KrakenApi implements PriceFeed {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (const time in priceHistory) {
 | 
					    for (const time in priceHistory) {
 | 
				
			||||||
 | 
					      if (priceHistory[time].USD === -1) {
 | 
				
			||||||
 | 
					        delete priceHistory[time];
 | 
				
			||||||
 | 
					        continue;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      await PricesRepository.$savePrices(parseInt(time, 10), priceHistory[time]);
 | 
					      await PricesRepository.$savePrices(parseInt(time, 10), priceHistory[time]);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -35,21 +35,23 @@ const getRectangle = ($el) => $el[0].getBoundingClientRect();
 | 
				
			|||||||
describe('Mainnet', () => {
 | 
					describe('Mainnet', () => {
 | 
				
			||||||
  beforeEach(() => {
 | 
					  beforeEach(() => {
 | 
				
			||||||
    //cy.intercept('/sockjs-node/info*').as('socket');
 | 
					    //cy.intercept('/sockjs-node/info*').as('socket');
 | 
				
			||||||
    cy.intercept('/api/block-height/*').as('block-height');
 | 
					    // cy.intercept('/api/block-height/*').as('block-height');
 | 
				
			||||||
    cy.intercept('/api/block/*').as('block');
 | 
					    // cy.intercept('/api/v1/block/*').as('block');
 | 
				
			||||||
    cy.intercept('/api/block/*/txs/0').as('block-txs');
 | 
					    // cy.intercept('/api/block/*/txs/0').as('block-txs');
 | 
				
			||||||
    cy.intercept('/api/tx/*/outspends').as('tx-outspends');
 | 
					    // cy.intercept('/api/v1/block/*/summary').as('block-summary');
 | 
				
			||||||
    cy.intercept('/resources/pools.json').as('pools');
 | 
					    // cy.intercept('/api/v1/outspends/*').as('outspends');
 | 
				
			||||||
 | 
					    // cy.intercept('/api/tx/*/outspends').as('tx-outspends');
 | 
				
			||||||
 | 
					    // cy.intercept('/resources/pools.json').as('pools');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Search Auto Complete
 | 
					    // Search Auto Complete
 | 
				
			||||||
    cy.intercept('/api/address-prefix/1wiz').as('search-1wiz');
 | 
					    cy.intercept('/api/address-prefix/1wiz').as('search-1wiz');
 | 
				
			||||||
    cy.intercept('/api/address-prefix/1wizS').as('search-1wizS');
 | 
					    cy.intercept('/api/address-prefix/1wizS').as('search-1wizS');
 | 
				
			||||||
    cy.intercept('/api/address-prefix/1wizSA').as('search-1wizSA');
 | 
					    cy.intercept('/api/address-prefix/1wizSA').as('search-1wizSA');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Cypress.Commands.add('waitForBlockData', () => {
 | 
					    // Cypress.Commands.add('waitForBlockData', () => {
 | 
				
			||||||
      cy.wait('@tx-outspends');
 | 
					    //   cy.wait('@tx-outspends');
 | 
				
			||||||
      cy.wait('@pools');
 | 
					    //   cy.wait('@pools');
 | 
				
			||||||
    });
 | 
					    // });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (baseModule === 'mempool') {
 | 
					  if (baseModule === 'mempool') {
 | 
				
			||||||
@ -409,7 +411,7 @@ describe('Mainnet', () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    it('loads the tv screen - desktop', () => {
 | 
					    it('loads the tv screen - desktop', () => {
 | 
				
			||||||
      cy.viewport('macbook-16');
 | 
					      cy.viewport('macbook-16');
 | 
				
			||||||
      cy.visit('/');
 | 
					      cy.visit('/graphs/mempool');
 | 
				
			||||||
      cy.waitForSkeletonGone();
 | 
					      cy.waitForSkeletonGone();
 | 
				
			||||||
      cy.get('#btn-tv').click().then(() => {
 | 
					      cy.get('#btn-tv').click().then(() => {
 | 
				
			||||||
        cy.viewport('macbook-16');
 | 
					        cy.viewport('macbook-16');
 | 
				
			||||||
 | 
				
			|||||||
@ -60,10 +60,10 @@ describe('Signet', () => {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    describe('tv mode', () => {
 | 
					    describe.skip('tv mode', () => {
 | 
				
			||||||
      it('loads the tv screen - desktop', () => {
 | 
					      it('loads the tv screen - desktop', () => {
 | 
				
			||||||
        cy.viewport('macbook-16');
 | 
					        cy.viewport('macbook-16');
 | 
				
			||||||
        cy.visit('/signet');
 | 
					        cy.visit('/signet/graphs');
 | 
				
			||||||
        cy.waitForSkeletonGone();
 | 
					        cy.waitForSkeletonGone();
 | 
				
			||||||
        cy.get('#btn-tv').click().then(() => {
 | 
					        cy.get('#btn-tv').click().then(() => {
 | 
				
			||||||
          cy.get('.chart-holder').should('be.visible');
 | 
					          cy.get('.chart-holder').should('be.visible');
 | 
				
			||||||
@ -73,19 +73,17 @@ describe('Signet', () => {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      it('loads the tv screen - mobile', () => {
 | 
					      it('loads the tv screen - mobile', () => {
 | 
				
			||||||
        cy.visit('/signet');
 | 
					        cy.visit('/signet/graphs');
 | 
				
			||||||
        cy.waitForSkeletonGone();
 | 
					        cy.waitForSkeletonGone();
 | 
				
			||||||
        cy.get('#btn-tv').click().then(() => {
 | 
					        cy.get('#btn-tv').click().then(() => {
 | 
				
			||||||
          cy.viewport('iphone-8');
 | 
					          cy.viewport('iphone-8');
 | 
				
			||||||
          cy.get('.chart-holder').should('be.visible');
 | 
					          cy.get('.chart-holder').should('be.visible');
 | 
				
			||||||
          cy.get('.tv-only').should('not.exist');
 | 
					          cy.get('.tv-only').should('not.exist');
 | 
				
			||||||
          //TODO: Remove comment when the bug is fixed
 | 
					          cy.get('#mempool-block-0').should('be.visible');
 | 
				
			||||||
          //cy.get('#mempool-block-0').should('be.visible');
 | 
					 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('loads the api screen', () => {
 | 
					    it('loads the api screen', () => {
 | 
				
			||||||
      cy.visit('/signet');
 | 
					      cy.visit('/signet');
 | 
				
			||||||
      cy.waitForSkeletonGone();
 | 
					      cy.waitForSkeletonGone();
 | 
				
			||||||
 | 
				
			|||||||
@ -63,18 +63,17 @@ describe('Testnet', () => {
 | 
				
			|||||||
    describe('tv mode', () => {
 | 
					    describe('tv mode', () => {
 | 
				
			||||||
      it('loads the tv screen - desktop', () => {
 | 
					      it('loads the tv screen - desktop', () => {
 | 
				
			||||||
        cy.viewport('macbook-16');
 | 
					        cy.viewport('macbook-16');
 | 
				
			||||||
        cy.visit('/testnet');
 | 
					        cy.visit('/testnet/graphs');
 | 
				
			||||||
        cy.waitForSkeletonGone();
 | 
					        cy.waitForSkeletonGone();
 | 
				
			||||||
        cy.get('#btn-tv').click().then(() => {
 | 
					        cy.get('#btn-tv').click().then(() => {
 | 
				
			||||||
          cy.wait(1000);
 | 
					          cy.wait(1000);
 | 
				
			||||||
          cy.get('.tv-only').should('not.exist');
 | 
					          cy.get('.tv-only').should('not.exist');
 | 
				
			||||||
          //TODO: Remove comment when the bug is fixed
 | 
					          cy.get('#mempool-block-0').should('be.visible');
 | 
				
			||||||
          //cy.get('#mempool-block-0').should('be.visible');
 | 
					 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      it('loads the tv screen - mobile', () => {
 | 
					      it('loads the tv screen - mobile', () => {
 | 
				
			||||||
        cy.visit('/testnet');
 | 
					        cy.visit('/testnet/graphs');
 | 
				
			||||||
        cy.waitForSkeletonGone();
 | 
					        cy.waitForSkeletonGone();
 | 
				
			||||||
        cy.get('#btn-tv').click().then(() => {
 | 
					        cy.get('#btn-tv').click().then(() => {
 | 
				
			||||||
          cy.viewport('iphone-6');
 | 
					          cy.viewport('iphone-6');
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										32
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										32
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -34,6 +34,7 @@
 | 
				
			|||||||
        "clipboard": "^2.0.10",
 | 
					        "clipboard": "^2.0.10",
 | 
				
			||||||
        "domino": "^2.1.6",
 | 
					        "domino": "^2.1.6",
 | 
				
			||||||
        "echarts": "~5.3.2",
 | 
					        "echarts": "~5.3.2",
 | 
				
			||||||
 | 
					        "echarts-gl": "^2.0.9",
 | 
				
			||||||
        "express": "^4.17.1",
 | 
					        "express": "^4.17.1",
 | 
				
			||||||
        "lightweight-charts": "~3.8.0",
 | 
					        "lightweight-charts": "~3.8.0",
 | 
				
			||||||
        "ngx-echarts": "8.0.1",
 | 
					        "ngx-echarts": "8.0.1",
 | 
				
			||||||
@ -6396,6 +6397,11 @@
 | 
				
			|||||||
        "webpack": ">=4.0.1"
 | 
					        "webpack": ">=4.0.1"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/claygl": {
 | 
				
			||||||
 | 
					      "version": "1.3.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ=="
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/clean-stack": {
 | 
					    "node_modules/clean-stack": {
 | 
				
			||||||
      "version": "2.2.0",
 | 
					      "version": "2.2.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
 | 
				
			||||||
@ -8107,6 +8113,18 @@
 | 
				
			|||||||
        "zrender": "5.3.1"
 | 
					        "zrender": "5.3.1"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/echarts-gl": {
 | 
				
			||||||
 | 
					      "version": "2.0.9",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "claygl": "^1.2.1",
 | 
				
			||||||
 | 
					        "zrender": "^5.1.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "echarts": "^5.1.2"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/echarts/node_modules/tslib": {
 | 
					    "node_modules/echarts/node_modules/tslib": {
 | 
				
			||||||
      "version": "2.3.0",
 | 
					      "version": "2.3.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
 | 
				
			||||||
@ -22520,6 +22538,11 @@
 | 
				
			|||||||
      "integrity": "sha512-g38K9Cm5WRwlaH6g03B9OEz/0qRizI+2I7n+Gz+L5DxXJAPAiWQvwlYNm1V1jkdpUv95bOe/ASm2vfi/G560jQ==",
 | 
					      "integrity": "sha512-g38K9Cm5WRwlaH6g03B9OEz/0qRizI+2I7n+Gz+L5DxXJAPAiWQvwlYNm1V1jkdpUv95bOe/ASm2vfi/G560jQ==",
 | 
				
			||||||
      "requires": {}
 | 
					      "requires": {}
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "claygl": {
 | 
				
			||||||
 | 
					      "version": "1.3.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ=="
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "clean-stack": {
 | 
					    "clean-stack": {
 | 
				
			||||||
      "version": "2.2.0",
 | 
					      "version": "2.2.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
 | 
				
			||||||
@ -23866,6 +23889,15 @@
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "echarts-gl": {
 | 
				
			||||||
 | 
					      "version": "2.0.9",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==",
 | 
				
			||||||
 | 
					      "requires": {
 | 
				
			||||||
 | 
					        "claygl": "^1.2.1",
 | 
				
			||||||
 | 
					        "zrender": "^5.1.1"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "ee-first": {
 | 
					    "ee-first": {
 | 
				
			||||||
      "version": "1.1.1",
 | 
					      "version": "1.1.1",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
 | 
				
			||||||
 | 
				
			|||||||
@ -88,6 +88,7 @@
 | 
				
			|||||||
    "clipboard": "^2.0.10",
 | 
					    "clipboard": "^2.0.10",
 | 
				
			||||||
    "domino": "^2.1.6",
 | 
					    "domino": "^2.1.6",
 | 
				
			||||||
    "echarts": "~5.3.2",
 | 
					    "echarts": "~5.3.2",
 | 
				
			||||||
 | 
					    "echarts-gl": "^2.0.9",
 | 
				
			||||||
    "express": "^4.17.1",
 | 
					    "express": "^4.17.1",
 | 
				
			||||||
    "lightweight-charts": "~3.8.0",
 | 
					    "lightweight-charts": "~3.8.0",
 | 
				
			||||||
    "ngx-echarts": "8.0.1",
 | 
					    "ngx-echarts": "8.0.1",
 | 
				
			||||||
 | 
				
			|||||||
@ -85,7 +85,7 @@ if (configContent && configContent.BASE_MODULE == "liquid") {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
} else {
 | 
					} else {
 | 
				
			||||||
    PROXY_CONFIG.push({
 | 
					    PROXY_CONFIG.push({
 | 
				
			||||||
        context: ['/resources/pools.json', '/resources/assets.json', '/resources/assets.minimal.json'],
 | 
					        context: ['/resources/pools.json', '/resources/assets.json', '/resources/assets.minimal.json', '/resources/worldmap.json'],
 | 
				
			||||||
        target: "https://mempool.space",
 | 
					        target: "https://mempool.space",
 | 
				
			||||||
        secure: false,
 | 
					        secure: false,
 | 
				
			||||||
        changeOrigin: true,
 | 
					        changeOrigin: true,
 | 
				
			||||||
 | 
				
			|||||||
@ -3,8 +3,11 @@ import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
 | 
				
			|||||||
import { StartComponent } from './components/start/start.component';
 | 
					import { StartComponent } from './components/start/start.component';
 | 
				
			||||||
import { TransactionComponent } from './components/transaction/transaction.component';
 | 
					import { TransactionComponent } from './components/transaction/transaction.component';
 | 
				
			||||||
import { BlockComponent } from './components/block/block.component';
 | 
					import { BlockComponent } from './components/block/block.component';
 | 
				
			||||||
 | 
					import { BlockAuditComponent } from './components/block-audit/block-audit.component';
 | 
				
			||||||
 | 
					import { BlockPreviewComponent } from './components/block/block-preview.component';
 | 
				
			||||||
import { AddressComponent } from './components/address/address.component';
 | 
					import { AddressComponent } from './components/address/address.component';
 | 
				
			||||||
import { MasterPageComponent } from './components/master-page/master-page.component';
 | 
					import { MasterPageComponent } from './components/master-page/master-page.component';
 | 
				
			||||||
 | 
					import { MasterPagePreviewComponent } from './components/master-page-preview/master-page-preview.component';
 | 
				
			||||||
import { AboutComponent } from './components/about/about.component';
 | 
					import { AboutComponent } from './components/about/about.component';
 | 
				
			||||||
import { StatusViewComponent } from './components/status-view/status-view.component';
 | 
					import { StatusViewComponent } from './components/status-view/status-view.component';
 | 
				
			||||||
import { TermsOfServiceComponent } from './components/terms-of-service/terms-of-service.component';
 | 
					import { TermsOfServiceComponent } from './components/terms-of-service/terms-of-service.component';
 | 
				
			||||||
@ -88,6 +91,15 @@ let routes: Routes = [
 | 
				
			|||||||
              },
 | 
					              },
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            path: 'block-audit',
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                path: ':id',
 | 
				
			||||||
 | 
					                component: BlockAuditComponent,
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            path: 'docs',
 | 
					            path: 'docs',
 | 
				
			||||||
            loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
					            loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
				
			||||||
@ -182,6 +194,15 @@ let routes: Routes = [
 | 
				
			|||||||
              },
 | 
					              },
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            path: 'block-audit',
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                path: ':id',
 | 
				
			||||||
 | 
					                component: BlockAuditComponent,
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            path: 'docs',
 | 
					            path: 'docs',
 | 
				
			||||||
            loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
					            loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
				
			||||||
@ -273,6 +294,15 @@ let routes: Routes = [
 | 
				
			|||||||
          },
 | 
					          },
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        path: 'block-audit',
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            path: ':id',
 | 
				
			||||||
 | 
					            component: BlockAuditComponent
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        path: 'docs',
 | 
					        path: 'docs',
 | 
				
			||||||
        loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
					        loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
				
			||||||
@ -287,6 +317,16 @@ let routes: Routes = [
 | 
				
			|||||||
      },
 | 
					      },
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    path: 'preview',
 | 
				
			||||||
 | 
					    component: MasterPagePreviewComponent,
 | 
				
			||||||
 | 
					    children: [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        path: 'block/:id',
 | 
				
			||||||
 | 
					        component: BlockPreviewComponent
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    path: 'status',
 | 
					    path: 'status',
 | 
				
			||||||
    component: StatusViewComponent
 | 
					    component: StatusViewComponent
 | 
				
			||||||
@ -548,4 +588,3 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
 | 
				
			|||||||
  })],
 | 
					  })],
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class AppRoutingModule { }
 | 
					export class AppRoutingModule { }
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,7 @@ import { AppRoutingModule } from './app-routing.module';
 | 
				
			|||||||
import { AppComponent } from './components/app/app.component';
 | 
					import { AppComponent } from './components/app/app.component';
 | 
				
			||||||
import { ElectrsApiService } from './services/electrs-api.service';
 | 
					import { ElectrsApiService } from './services/electrs-api.service';
 | 
				
			||||||
import { StateService } from './services/state.service';
 | 
					import { StateService } from './services/state.service';
 | 
				
			||||||
 | 
					import { EnterpriseService } from './services/enterprise.service';
 | 
				
			||||||
import { WebsocketService } from './services/websocket.service';
 | 
					import { WebsocketService } from './services/websocket.service';
 | 
				
			||||||
import { AudioService } from './services/audio.service';
 | 
					import { AudioService } from './services/audio.service';
 | 
				
			||||||
import { SeoService } from './services/seo.service';
 | 
					import { SeoService } from './services/seo.service';
 | 
				
			||||||
@ -36,6 +37,7 @@ import { CapAddressPipe } from './shared/pipes/cap-address-pipe/cap-address-pipe
 | 
				
			|||||||
    AudioService,
 | 
					    AudioService,
 | 
				
			||||||
    SeoService,
 | 
					    SeoService,
 | 
				
			||||||
    StorageService,
 | 
					    StorageService,
 | 
				
			||||||
 | 
					    EnterpriseService,
 | 
				
			||||||
    LanguageService,
 | 
					    LanguageService,
 | 
				
			||||||
    ShortenStringPipe,
 | 
					    ShortenStringPipe,
 | 
				
			||||||
    FiatShortenerPipe,
 | 
					    FiatShortenerPipe,
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,111 @@
 | 
				
			|||||||
 | 
					<div class="container-xl" (window:resize)="onResize($event)">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div *ngIf="(auditObservable$ | async) as blockAudit; else skeleton">
 | 
				
			||||||
 | 
					    <div class="title-block" id="block">
 | 
				
			||||||
 | 
					      <h1>
 | 
				
			||||||
 | 
					        <span class="next-previous-blocks">
 | 
				
			||||||
 | 
					          <span i18n="shared.block-title">Block </span>
 | 
				
			||||||
 | 
					           
 | 
				
			||||||
 | 
					          <a [routerLink]="['/block/' | relativeUrl, blockAudit.id]">{{ blockAudit.height }}</a>
 | 
				
			||||||
 | 
					           
 | 
				
			||||||
 | 
					          <span i18n="shared.template-vs-mined">Template vs Mined</span>
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="grow"></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">✕</button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- OVERVIEW -->
 | 
				
			||||||
 | 
					    <div class="box mb-3">
 | 
				
			||||||
 | 
					      <div class="row">
 | 
				
			||||||
 | 
					        <!-- LEFT COLUMN -->
 | 
				
			||||||
 | 
					        <div class="col-sm">
 | 
				
			||||||
 | 
					          <table class="table table-borderless table-striped">
 | 
				
			||||||
 | 
					            <tbody>
 | 
				
			||||||
 | 
					              <tr>
 | 
				
			||||||
 | 
					                <td class="td-width" i18n="block.hash">Hash</td>
 | 
				
			||||||
 | 
					                <td><a [routerLink]="['/block/' | relativeUrl, blockAudit.id]" title="{{ blockAudit.id }}">{{ blockAudit.id | shortenString : 13 }}</a>
 | 
				
			||||||
 | 
					                  <app-clipboard class="d-none d-sm-inline-block" [text]="blockAudit.id"></app-clipboard>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					              <tr>
 | 
				
			||||||
 | 
					                <td i18n="blockAudit.timestamp">Timestamp</td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                  ‎{{ blockAudit.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
 | 
				
			||||||
 | 
					                  <div class="lg-inline">
 | 
				
			||||||
 | 
					                    <i class="symbol">(<app-time-since [time]="blockAudit.timestamp" [fastRender]="true">
 | 
				
			||||||
 | 
					                      </app-time-since>)</i>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					              <tr>
 | 
				
			||||||
 | 
					                <td i18n="blockAudit.size">Size</td>
 | 
				
			||||||
 | 
					                <td [innerHTML]="'‎' + (blockAudit.size | bytes: 2)"></td>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					              <tr>
 | 
				
			||||||
 | 
					                <td i18n="block.weight">Weight</td>
 | 
				
			||||||
 | 
					                <td [innerHTML]="'‎' + (blockAudit.weight | wuBytes: 2)"></td>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					            </tbody>
 | 
				
			||||||
 | 
					          </table>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <!-- RIGHT COLUMN -->
 | 
				
			||||||
 | 
					        <div class="col-sm" *ngIf="blockAudit">
 | 
				
			||||||
 | 
					          <table class="table table-borderless table-striped">
 | 
				
			||||||
 | 
					            <tbody>
 | 
				
			||||||
 | 
					              <tr>
 | 
				
			||||||
 | 
					                <td class="td-width" i18n="shared.transaction-count">Transactions</td>
 | 
				
			||||||
 | 
					                <td>{{ blockAudit.tx_count }}</td>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					              <tr>
 | 
				
			||||||
 | 
					                <td i18n="block.match-rate">Match rate</td>
 | 
				
			||||||
 | 
					                <td>{{ blockAudit.matchRate }}%</td>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					              <tr>
 | 
				
			||||||
 | 
					                <td i18n="block.missing-txs">Missing txs</td>
 | 
				
			||||||
 | 
					                <td>{{ blockAudit.missingTxs.length }}</td>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					              <tr>
 | 
				
			||||||
 | 
					                <td i18n="block.added-txs">Added txs</td>
 | 
				
			||||||
 | 
					                <td>{{ blockAudit.addedTxs.length }}</td>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					            </tbody>
 | 
				
			||||||
 | 
					          </table>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div> <!-- row -->
 | 
				
			||||||
 | 
					    </div> <!-- box -->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- ADDED vs MISSING button -->
 | 
				
			||||||
 | 
					    <div class="d-flex justify-content-center menu mt-3" *ngIf="isMobile">
 | 
				
			||||||
 | 
					      <a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.missing-txs"
 | 
				
			||||||
 | 
					        fragment="missing" (click)="changeMode('missing')">Missing</a>
 | 
				
			||||||
 | 
					      <a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.added-txs"
 | 
				
			||||||
 | 
					        fragment="added" (click)="changeMode('added')">Added</a>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <!-- VISUALIZATIONS -->
 | 
				
			||||||
 | 
					  <div class="box">
 | 
				
			||||||
 | 
					    <div class="row">
 | 
				
			||||||
 | 
					      <!-- MISSING TX RENDERING -->
 | 
				
			||||||
 | 
					      <div class="col-sm" *ngIf="webGlEnabled">
 | 
				
			||||||
 | 
					        <app-block-overview-graph #blockGraphTemplate [isLoading]="isLoading" [resolution]="75"
 | 
				
			||||||
 | 
					          [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
 | 
				
			||||||
 | 
					          (txClickEvent)="onTxClick($event)"></app-block-overview-graph>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <!-- ADDED TX RENDERING -->
 | 
				
			||||||
 | 
					      <div class="col-sm" *ngIf="webGlEnabled && !isMobile">
 | 
				
			||||||
 | 
					        <app-block-overview-graph #blockGraphMined [isLoading]="isLoading" [resolution]="75"
 | 
				
			||||||
 | 
					          [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
 | 
				
			||||||
 | 
					          (txClickEvent)="onTxClick($event)"></app-block-overview-graph>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div> <!-- row -->
 | 
				
			||||||
 | 
					  </div> <!-- box -->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <ng-template #skeleton></ng-template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					.title-block {
 | 
				
			||||||
 | 
					  border-top: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.table {
 | 
				
			||||||
 | 
					  tr td {
 | 
				
			||||||
 | 
					    &:last-child {
 | 
				
			||||||
 | 
					      text-align: right;
 | 
				
			||||||
 | 
					      @media (min-width: 768px) {
 | 
				
			||||||
 | 
					        text-align: left;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.block-tx-title {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  @media (min-width: 550px) {
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  h2 {
 | 
				
			||||||
 | 
					    line-height: 1;
 | 
				
			||||||
 | 
					    margin: 0;
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					    padding-bottom: 10px;
 | 
				
			||||||
 | 
					    @media (min-width: 550px) {
 | 
				
			||||||
 | 
					      padding-bottom: 0px;
 | 
				
			||||||
 | 
					      align-self: end;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.menu-button {
 | 
				
			||||||
 | 
					  @media (min-width: 768px) {
 | 
				
			||||||
 | 
					    max-width: 150px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										120
									
								
								frontend/src/app/components/block-audit/block-audit.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								frontend/src/app/components/block-audit/block-audit.component.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,120 @@
 | 
				
			|||||||
 | 
					import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
 | 
				
			||||||
 | 
					import { ActivatedRoute, ParamMap, Router } from '@angular/router';
 | 
				
			||||||
 | 
					import { Observable } from 'rxjs';
 | 
				
			||||||
 | 
					import { map, share, switchMap, tap } from 'rxjs/operators';
 | 
				
			||||||
 | 
					import { BlockAudit, TransactionStripped } from 'src/app/interfaces/node-api.interface';
 | 
				
			||||||
 | 
					import { ApiService } from 'src/app/services/api.service';
 | 
				
			||||||
 | 
					import { StateService } from 'src/app/services/state.service';
 | 
				
			||||||
 | 
					import { detectWebGL } from 'src/app/shared/graphs.utils';
 | 
				
			||||||
 | 
					import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
 | 
				
			||||||
 | 
					import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-block-audit',
 | 
				
			||||||
 | 
					  templateUrl: './block-audit.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./block-audit.component.scss'],
 | 
				
			||||||
 | 
					  styles: [`
 | 
				
			||||||
 | 
					    .loadingGraphs {
 | 
				
			||||||
 | 
					      position: absolute;
 | 
				
			||||||
 | 
					      top: 50%;
 | 
				
			||||||
 | 
					      left: calc(50% - 15px);
 | 
				
			||||||
 | 
					      z-index: 100;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  `],
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class BlockAuditComponent implements OnInit, OnDestroy {
 | 
				
			||||||
 | 
					  blockAudit: BlockAudit = undefined;
 | 
				
			||||||
 | 
					  transactions: string[];
 | 
				
			||||||
 | 
					  auditObservable$: Observable<BlockAudit>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  paginationMaxSize: number;
 | 
				
			||||||
 | 
					  page = 1;
 | 
				
			||||||
 | 
					  itemsPerPage: number;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  mode: 'missing' | 'added' = 'missing';
 | 
				
			||||||
 | 
					  isLoading = true;
 | 
				
			||||||
 | 
					  webGlEnabled = true;
 | 
				
			||||||
 | 
					  isMobile = window.innerWidth <= 767.98;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @ViewChild('blockGraphTemplate') blockGraphTemplate: BlockOverviewGraphComponent;
 | 
				
			||||||
 | 
					  @ViewChild('blockGraphMined') blockGraphMined: BlockOverviewGraphComponent;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private route: ActivatedRoute,
 | 
				
			||||||
 | 
					    public stateService: StateService,
 | 
				
			||||||
 | 
					    private router: Router,
 | 
				
			||||||
 | 
					    private apiService: ApiService
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    this.webGlEnabled = detectWebGL();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnDestroy(): void {
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnInit(): void {
 | 
				
			||||||
 | 
					    this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
 | 
				
			||||||
 | 
					    this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.auditObservable$ = this.route.paramMap.pipe(
 | 
				
			||||||
 | 
					      switchMap((params: ParamMap) => {
 | 
				
			||||||
 | 
					        const blockHash: string = params.get('id') || '';
 | 
				
			||||||
 | 
					        return this.apiService.getBlockAudit$(blockHash)
 | 
				
			||||||
 | 
					          .pipe(
 | 
				
			||||||
 | 
					            map((response) => {
 | 
				
			||||||
 | 
					              const blockAudit = response.body;
 | 
				
			||||||
 | 
					              for (let i = 0; i < blockAudit.template.length; ++i) {
 | 
				
			||||||
 | 
					                if (blockAudit.missingTxs.includes(blockAudit.template[i].txid)) {
 | 
				
			||||||
 | 
					                  blockAudit.template[i].status = 'missing';
 | 
				
			||||||
 | 
					                } else if (blockAudit.addedTxs.includes(blockAudit.template[i].txid)) {
 | 
				
			||||||
 | 
					                  blockAudit.template[i].status = 'added';
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                  blockAudit.template[i].status = 'found';
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              for (let i = 0; i < blockAudit.transactions.length; ++i) {
 | 
				
			||||||
 | 
					                if (blockAudit.missingTxs.includes(blockAudit.transactions[i].txid)) {
 | 
				
			||||||
 | 
					                  blockAudit.transactions[i].status = 'missing';
 | 
				
			||||||
 | 
					                } else if (blockAudit.addedTxs.includes(blockAudit.transactions[i].txid)) {
 | 
				
			||||||
 | 
					                  blockAudit.transactions[i].status = 'added';
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                  blockAudit.transactions[i].status = 'found';
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              return blockAudit;
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
 | 
					            tap((blockAudit) => {
 | 
				
			||||||
 | 
					              this.changeMode(this.mode);
 | 
				
			||||||
 | 
					              if (this.blockGraphTemplate) {
 | 
				
			||||||
 | 
					                this.blockGraphTemplate.destroy();
 | 
				
			||||||
 | 
					                this.blockGraphTemplate.setup(blockAudit.template);
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              if (this.blockGraphMined) {
 | 
				
			||||||
 | 
					                this.blockGraphMined.destroy();
 | 
				
			||||||
 | 
					                this.blockGraphMined.setup(blockAudit.transactions);
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              this.isLoading = false;
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					      share()
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onResize(event: any) {
 | 
				
			||||||
 | 
					    this.isMobile = event.target.innerWidth <= 767.98;
 | 
				
			||||||
 | 
					    this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  changeMode(mode: 'missing' | 'added') {
 | 
				
			||||||
 | 
					    this.router.navigate([], { fragment: mode });
 | 
				
			||||||
 | 
					    this.mode = mode;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onTxClick(event: TransactionStripped): void {
 | 
				
			||||||
 | 
					    const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
 | 
				
			||||||
 | 
					    this.router.navigate([url]);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  pageChange(page: number, target: HTMLElement) {
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -2,10 +2,13 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<div class="full-container">
 | 
					<div class="full-container">
 | 
				
			||||||
  <div class="card-header mb-0 mb-md-4">
 | 
					  <div class="card-header mb-0 mb-md-4">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
      <span i18n="mining.block-fee-rates">Block Fee Rates</span>
 | 
					      <span i18n="mining.block-fee-rates">Block Fee Rates</span>
 | 
				
			||||||
    <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144">
 | 
					        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144">
 | 
				
			||||||
 | 
				
			|||||||
@ -2,10 +2,13 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<div class="full-container">
 | 
					<div class="full-container">
 | 
				
			||||||
  <div class="card-header mb-0 mb-md-4">
 | 
					  <div class="card-header mb-0 mb-md-4">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
      <span i18n="mining.block-fees">Block Fees</span>
 | 
					      <span i18n="mining.block-fees">Block Fees</span>
 | 
				
			||||||
      <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320">
 | 
					        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320">
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,5 @@
 | 
				
			|||||||
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core';
 | 
					import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core';
 | 
				
			||||||
import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface';
 | 
					import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
 | 
				
			||||||
import { WebsocketService } from 'src/app/services/websocket.service';
 | 
					 | 
				
			||||||
import { FastVertexArray } from './fast-vertex-array';
 | 
					import { FastVertexArray } from './fast-vertex-array';
 | 
				
			||||||
import BlockScene from './block-scene';
 | 
					import BlockScene from './block-scene';
 | 
				
			||||||
import TxSprite from './tx-sprite';
 | 
					import TxSprite from './tx-sprite';
 | 
				
			||||||
 | 
				
			|||||||
@ -25,6 +25,7 @@ export default class TxView implements TransactionStripped {
 | 
				
			|||||||
  vsize: number;
 | 
					  vsize: number;
 | 
				
			||||||
  value: number;
 | 
					  value: number;
 | 
				
			||||||
  feerate: number;
 | 
					  feerate: number;
 | 
				
			||||||
 | 
					  status?: 'found' | 'missing' | 'added';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  initialised: boolean;
 | 
					  initialised: boolean;
 | 
				
			||||||
  vertexArray: FastVertexArray;
 | 
					  vertexArray: FastVertexArray;
 | 
				
			||||||
@ -43,6 +44,7 @@ export default class TxView implements TransactionStripped {
 | 
				
			|||||||
    this.vsize = tx.vsize;
 | 
					    this.vsize = tx.vsize;
 | 
				
			||||||
    this.value = tx.value;
 | 
					    this.value = tx.value;
 | 
				
			||||||
    this.feerate = tx.fee / tx.vsize;
 | 
					    this.feerate = tx.fee / tx.vsize;
 | 
				
			||||||
 | 
					    this.status = tx.status;
 | 
				
			||||||
    this.initialised = false;
 | 
					    this.initialised = false;
 | 
				
			||||||
    this.vertexArray = vertexArray;
 | 
					    this.vertexArray = vertexArray;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -140,6 +142,14 @@ export default class TxView implements TransactionStripped {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getColor(): Color {
 | 
					  getColor(): Color {
 | 
				
			||||||
 | 
					    // Block audit
 | 
				
			||||||
 | 
					    if (this.status === 'missing') {
 | 
				
			||||||
 | 
					      return hexToColor('039BE5');
 | 
				
			||||||
 | 
					    } else if (this.status === 'added') {
 | 
				
			||||||
 | 
					      return hexToColor('D81B60');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Block component
 | 
				
			||||||
    const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1;
 | 
					    const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1;
 | 
				
			||||||
    return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]);
 | 
					    return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
@ -2,10 +2,13 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<div class="full-container">
 | 
					<div class="full-container">
 | 
				
			||||||
  <div class="card-header mb-0 mb-md-4">
 | 
					  <div class="card-header mb-0 mb-md-4">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
      <span i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</span>
 | 
					      <span i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</span>
 | 
				
			||||||
      <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144">
 | 
					        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144">
 | 
				
			||||||
 | 
				
			|||||||
@ -98,7 +98,21 @@ export class BlockPredictionGraphComponent implements OnInit {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  prepareChartOptions(data) {
 | 
					  prepareChartOptions(data) {
 | 
				
			||||||
 | 
					    let title: object;
 | 
				
			||||||
 | 
					    if (data.length === 0) {
 | 
				
			||||||
 | 
					      title = {
 | 
				
			||||||
 | 
					        textStyle: {
 | 
				
			||||||
 | 
					          color: 'grey',
 | 
				
			||||||
 | 
					          fontSize: 15
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        text: $localize`No data to display yet. Try again later.`,
 | 
				
			||||||
 | 
					        left: 'center',
 | 
				
			||||||
 | 
					        top: 'center'
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.chartOptions = {
 | 
					    this.chartOptions = {
 | 
				
			||||||
 | 
					      title: data.length === 0 ? title : undefined,
 | 
				
			||||||
      animation: false,
 | 
					      animation: false,
 | 
				
			||||||
      grid: {
 | 
					      grid: {
 | 
				
			||||||
        top: 30,
 | 
					        top: 30,
 | 
				
			||||||
@ -133,17 +147,16 @@ export class BlockPredictionGraphComponent implements OnInit {
 | 
				
			|||||||
          return tooltip;
 | 
					          return tooltip;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      xAxis: {
 | 
					      xAxis: data.length === 0 ? undefined : {
 | 
				
			||||||
        name: formatterXAxisLabel(this.locale, this.timespan),
 | 
					        name: formatterXAxisLabel(this.locale, this.timespan),
 | 
				
			||||||
        nameLocation: 'middle',
 | 
					        nameLocation: 'middle',
 | 
				
			||||||
        nameTextStyle: {
 | 
					        nameTextStyle: {
 | 
				
			||||||
          padding: [10, 0, 0, 0],
 | 
					          padding: [10, 0, 0, 0],
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        type: 'category',
 | 
					        type: 'category',
 | 
				
			||||||
        boundaryGap: false,
 | 
					 | 
				
			||||||
        axisLine: { onZero: true },
 | 
					        axisLine: { onZero: true },
 | 
				
			||||||
        axisLabel: {
 | 
					        axisLabel: {
 | 
				
			||||||
          formatter: val => formatterXAxisTimeCategory(this.locale, this.timespan, parseInt(val, 10)),
 | 
					          formatter: val => formatterXAxisTimeCategory(this.locale, this.timespan, parseInt(val, 10) * 1000),
 | 
				
			||||||
          align: 'center',
 | 
					          align: 'center',
 | 
				
			||||||
          fontSize: 11,
 | 
					          fontSize: 11,
 | 
				
			||||||
          lineHeight: 12,
 | 
					          lineHeight: 12,
 | 
				
			||||||
@ -152,7 +165,7 @@ export class BlockPredictionGraphComponent implements OnInit {
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
        data: data.map(prediction => prediction[0])
 | 
					        data: data.map(prediction => prediction[0])
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      yAxis: [
 | 
					      yAxis: data.length === 0 ? undefined : [
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          type: 'value',
 | 
					          type: 'value',
 | 
				
			||||||
          axisLabel: {
 | 
					          axisLabel: {
 | 
				
			||||||
@ -170,7 +183,7 @@ export class BlockPredictionGraphComponent implements OnInit {
 | 
				
			|||||||
          },
 | 
					          },
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      ],
 | 
					      ],
 | 
				
			||||||
      series: [
 | 
					      series: data.length === 0 ? undefined : [
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          zlevel: 0,
 | 
					          zlevel: 0,
 | 
				
			||||||
          name: $localize`Match rate`,
 | 
					          name: $localize`Match rate`,
 | 
				
			||||||
@ -183,9 +196,10 @@ export class BlockPredictionGraphComponent implements OnInit {
 | 
				
			|||||||
          })),
 | 
					          })),
 | 
				
			||||||
          type: 'bar',
 | 
					          type: 'bar',
 | 
				
			||||||
          barWidth: '90%',
 | 
					          barWidth: '90%',
 | 
				
			||||||
 | 
					          barMaxWidth: 50,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      ],
 | 
					      ],
 | 
				
			||||||
      dataZoom: [{
 | 
					      dataZoom: data.length === 0 ? undefined : [{
 | 
				
			||||||
        type: 'inside',
 | 
					        type: 'inside',
 | 
				
			||||||
        realtime: true,
 | 
					        realtime: true,
 | 
				
			||||||
        zoomLock: true,
 | 
					        zoomLock: true,
 | 
				
			||||||
 | 
				
			|||||||
@ -3,10 +3,13 @@
 | 
				
			|||||||
<div class="full-container">
 | 
					<div class="full-container">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="card-header mb-0 mb-md-4">
 | 
					  <div class="card-header mb-0 mb-md-4">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
      <span i18n="mining.block-rewards">Block Rewards</span>
 | 
					      <span i18n="mining.block-rewards">Block Rewards</span>
 | 
				
			||||||
    <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>  
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320">
 | 
					        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320">
 | 
				
			||||||
 | 
				
			|||||||
@ -1,10 +1,12 @@
 | 
				
			|||||||
<div class="full-container">
 | 
					<div class="full-container">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="card-header mb-0 mb-md-4">
 | 
					  <div class="card-header mb-0 mb-md-4">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
      <span i18n="mining.block-sizes-weights">Block Sizes and Weights</span>
 | 
					      <span i18n="mining.block-sizes-weights">Block Sizes and Weights</span>
 | 
				
			||||||
    <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats">
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,92 @@
 | 
				
			|||||||
 | 
					<div class="box preview-box" *ngIf="!error">
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="col-sm">
 | 
				
			||||||
 | 
					      <h1 class="block-title">
 | 
				
			||||||
 | 
					        <ng-template [ngIf]="blockHeight === 0"><ng-container i18n="@@2303359202781425764">Genesis</ng-container>
 | 
				
			||||||
 | 
					          <span class="next-previous-blocks">
 | 
				
			||||||
 | 
					            <a [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockHeight }}</a>
 | 
				
			||||||
 | 
					          </span>
 | 
				
			||||||
 | 
					        </ng-template>
 | 
				
			||||||
 | 
					        <ng-template [ngIf]="blockHeight" i18n="shared.block-title">Block <ng-container *ngTemplateOutlet="blockTemplateContent"></ng-container></ng-template>
 | 
				
			||||||
 | 
					        <ng-template #blockTemplateContent>
 | 
				
			||||||
 | 
					          <span class="next-previous-blocks">
 | 
				
			||||||
 | 
					            <a [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockHeight }}</a>
 | 
				
			||||||
 | 
					          </span>
 | 
				
			||||||
 | 
					        </ng-template>
 | 
				
			||||||
 | 
					      </h1>
 | 
				
			||||||
 | 
					      <table class="table table-borderless table-striped">
 | 
				
			||||||
 | 
					        <tbody>
 | 
				
			||||||
 | 
					          <!-- <tr>
 | 
				
			||||||
 | 
					          <td class="td-width" i18n="block.hash">Hash</td>
 | 
				
			||||||
 | 
					          <td>‎<a [routerLink]="['/block/' | relativeUrl, block?.id]" title="{{ block?.id }}">{{ block?.id | shortenString : 13 }}</a></td>
 | 
				
			||||||
 | 
					          </tr> -->
 | 
				
			||||||
 | 
					          <tr>
 | 
				
			||||||
 | 
					            <td i18n="block.timestamp">Timestamp</td>
 | 
				
			||||||
 | 
					            <td>
 | 
				
			||||||
 | 
					              {{ block?.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
 | 
				
			||||||
 | 
					            </td>
 | 
				
			||||||
 | 
					          </tr>
 | 
				
			||||||
 | 
					          <tr>
 | 
				
			||||||
 | 
					            <td i18n="block.size">Size</td>
 | 
				
			||||||
 | 
					            <td [innerHTML]="'‎' + (block?.size | bytes: 2)"></td>
 | 
				
			||||||
 | 
					          </tr>
 | 
				
			||||||
 | 
					          <tr>
 | 
				
			||||||
 | 
					            <td i18n="block.weight">Weight</td>
 | 
				
			||||||
 | 
					            <td [innerHTML]="'‎' + (block?.weight | wuBytes: 2)"></td>
 | 
				
			||||||
 | 
					          </tr>
 | 
				
			||||||
 | 
					          <ng-template [ngIf]="webGlEnabled">
 | 
				
			||||||
 | 
					            <tr *ngIf="block?.extras?.medianFee != undefined">
 | 
				
			||||||
 | 
					              <td class="td-width" i18n="block.median-fee">Median fee</td>
 | 
				
			||||||
 | 
					              <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <ng-template [ngIf]="fees !== undefined">
 | 
				
			||||||
 | 
					              <tr>
 | 
				
			||||||
 | 
					                <td i18n="block.total-fees|Total fees in a block">Total fees</td>
 | 
				
			||||||
 | 
					                <td *ngIf="network !== 'liquid' && network !== 'liquidtestnet'; else liquidTotalFees">
 | 
				
			||||||
 | 
					                  <app-amount [satoshis]="block?.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <ng-template #liquidTotalFees>
 | 
				
			||||||
 | 
					                  <td>
 | 
				
			||||||
 | 
					                    <app-amount [satoshis]="fees * 100000000" digitsInfo="1.2-2" [noFiat]="true"></app-amount>
 | 
				
			||||||
 | 
					                  </td>
 | 
				
			||||||
 | 
					                </ng-template>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					              <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
 | 
				
			||||||
 | 
					                <td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                  <app-amount [satoshis]="block?.extras.reward" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					              </tr>
 | 
				
			||||||
 | 
					            </ng-template>
 | 
				
			||||||
 | 
					            <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
 | 
				
			||||||
 | 
					              <td i18n="block.miner">Miner</td>
 | 
				
			||||||
 | 
					              <td *ngIf="stateService.env.MINING_DASHBOARD">
 | 
				
			||||||
 | 
					                <a [attr.data-cy]="'block-details-miner-badge'" placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block?.extras.pool.slug]" class="badge"
 | 
				
			||||||
 | 
					                  [class]="block?.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
 | 
				
			||||||
 | 
					                  {{ block?.extras.pool.name }}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </td>
 | 
				
			||||||
 | 
					              <td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'">
 | 
				
			||||||
 | 
					                <span [attr.data-cy]="'block-details-miner-badge'" placement="bottom" class="badge"
 | 
				
			||||||
 | 
					                  [class]="block?.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
 | 
				
			||||||
 | 
					                  {{ block?.extras.pool.name }}
 | 
				
			||||||
 | 
					              </span>
 | 
				
			||||||
 | 
					              </td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					          </ng-template>
 | 
				
			||||||
 | 
					        </tbody>
 | 
				
			||||||
 | 
					      </table>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="col-sm chart-container" *ngIf="webGlEnabled">
 | 
				
			||||||
 | 
					      <app-block-overview-graph
 | 
				
			||||||
 | 
					        #blockGraph
 | 
				
			||||||
 | 
					        [isLoading]="false"
 | 
				
			||||||
 | 
					        [resolution]="75"
 | 
				
			||||||
 | 
					        [blockLimit]="stateService.blockVSize"
 | 
				
			||||||
 | 
					        [orientation]="'top'"
 | 
				
			||||||
 | 
					        [flip]="false"
 | 
				
			||||||
 | 
					        (txClickEvent)="onTxClick($event)"
 | 
				
			||||||
 | 
					      ></app-block-overview-graph>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					.box {
 | 
				
			||||||
 | 
					  padding: 2rem 6rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.block-title {
 | 
				
			||||||
 | 
					  margin-bottom: 0.5em;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										11
									
								
								frontend/src/app/components/block/block-preview.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/app/components/block/block-preview.component.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
				
			|||||||
 | 
					import { Component } from '@angular/core';
 | 
				
			||||||
 | 
					import { BlockComponent } from './block.component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-block-preview',
 | 
				
			||||||
 | 
					  templateUrl: './block-preview.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./block.component.scss', './block-preview.component.scss']
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class BlockPreviewComponent extends BlockComponent {
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -12,6 +12,7 @@ import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.
 | 
				
			|||||||
import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface';
 | 
					import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface';
 | 
				
			||||||
import { ApiService } from 'src/app/services/api.service';
 | 
					import { ApiService } from 'src/app/services/api.service';
 | 
				
			||||||
import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
 | 
					import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
 | 
				
			||||||
 | 
					import { detectWebGL } from 'src/app/shared/graphs.utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Component({
 | 
					@Component({
 | 
				
			||||||
  selector: 'app-block',
 | 
					  selector: 'app-block',
 | 
				
			||||||
@ -391,9 +392,3 @@ export class BlockComponent implements OnInit, OnDestroy {
 | 
				
			|||||||
    this.router.navigate([url]);
 | 
					    this.router.navigate([url]);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
function detectWebGL() {
 | 
					 | 
				
			||||||
  const canvas = document.createElement('canvas');
 | 
					 | 
				
			||||||
  const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
 | 
					 | 
				
			||||||
  return (gl && gl instanceof WebGLRenderingContext);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -31,9 +31,17 @@
 | 
				
			|||||||
    <button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
 | 
					    <button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
 | 
				
			||||||
    <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
 | 
					    <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
 | 
				
			||||||
      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]"
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]"
 | 
				
			||||||
        i18n="lightning.nodes-networks">Nodes per network</a>
 | 
					        i18n="lightning.nodes-networks">Lightning nodes per network</a>
 | 
				
			||||||
      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
 | 
				
			||||||
        i18n="lightning.capacity">Network capacity</a>
 | 
					        i18n="lightning.capacity">Network capacity</a>
 | 
				
			||||||
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]"
 | 
				
			||||||
 | 
					        i18n="lightning.nodes-per-isp">Lightning nodes per ISP</a>
 | 
				
			||||||
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-country' | relativeUrl]"
 | 
				
			||||||
 | 
					        i18n="lightning.nodes-per-isp">Lightning nodes per country</a>
 | 
				
			||||||
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-map' | relativeUrl]"
 | 
				
			||||||
 | 
					        i18n="lightning.lightning.nodes-heatmap">Lightning nodes world map</a>
 | 
				
			||||||
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-channels-map' | relativeUrl]"
 | 
				
			||||||
 | 
					        i18n="lightning.nodes-channels-world-map">Lightning nodes channels world map</a>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -23,10 +23,12 @@
 | 
				
			|||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
 | 
					  <div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
      <span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span>
 | 
					      <span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span>
 | 
				
			||||||
    <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
 | 
				
			|||||||
@ -109,7 +109,7 @@ export class HashrateChartComponent implements OnInit {
 | 
				
			|||||||
                    while (hashIndex < data.hashrates.length) {
 | 
					                    while (hashIndex < data.hashrates.length) {
 | 
				
			||||||
                      diffFixed.push({
 | 
					                      diffFixed.push({
 | 
				
			||||||
                        timestamp: data.hashrates[hashIndex].timestamp,
 | 
					                        timestamp: data.hashrates[hashIndex].timestamp,
 | 
				
			||||||
                        difficulty: data.difficulty[data.difficulty.length - 1].difficulty
 | 
					                        difficulty: data.difficulty.length > 0 ?  data.difficulty[data.difficulty.length - 1].difficulty : null
 | 
				
			||||||
                      });
 | 
					                      });
 | 
				
			||||||
                      ++hashIndex;
 | 
					                      ++hashIndex;
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
@ -231,11 +231,15 @@ export class HashrateChartComponent implements OnInit {
 | 
				
			|||||||
            } else if (tick.seriesIndex === 1) { // Difficulty
 | 
					            } else if (tick.seriesIndex === 1) { // Difficulty
 | 
				
			||||||
              let difficultyPowerOfTen = hashratePowerOfTen;
 | 
					              let difficultyPowerOfTen = hashratePowerOfTen;
 | 
				
			||||||
              let difficulty = tick.data[1];
 | 
					              let difficulty = tick.data[1];
 | 
				
			||||||
 | 
					              if (difficulty === null) {
 | 
				
			||||||
 | 
					                difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`;  
 | 
				
			||||||
 | 
					              } else {
 | 
				
			||||||
                if (this.isMobile()) {
 | 
					                if (this.isMobile()) {
 | 
				
			||||||
                  difficultyPowerOfTen = selectPowerOfTen(tick.data[1]);
 | 
					                  difficultyPowerOfTen = selectPowerOfTen(tick.data[1]);
 | 
				
			||||||
                  difficulty = Math.round(tick.data[1] / difficultyPowerOfTen.divider);
 | 
					                  difficulty = Math.round(tick.data[1] / difficultyPowerOfTen.divider);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                difficultyString = `${tick.marker} ${tick.seriesName}: ${formatNumber(difficulty, this.locale, '1.2-2')} ${difficultyPowerOfTen.unit}<br>`;
 | 
					                difficultyString = `${tick.marker} ${tick.seriesName}: ${formatNumber(difficulty, this.locale, '1.2-2')} ${difficultyPowerOfTen.unit}<br>`;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
            } else if (tick.seriesIndex === 2) { // Hashrate MA
 | 
					            } else if (tick.seriesIndex === 2) { // Hashrate MA
 | 
				
			||||||
              let hashrate = tick.data[1];
 | 
					              let hashrate = tick.data[1];
 | 
				
			||||||
              if (this.isMobile()) {
 | 
					              if (this.isMobile()) {
 | 
				
			||||||
 | 
				
			|||||||
@ -3,10 +3,13 @@
 | 
				
			|||||||
<div class="full-container">
 | 
					<div class="full-container">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="card-header mb-0 mb-md-4">
 | 
					  <div class="card-header mb-0 mb-md-4">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
      <span i18n="mining.pools-dominance">Pools Dominance</span>
 | 
					      <span i18n="mining.pools-dominance">Pools Dominance</span>
 | 
				
			||||||
    <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920">
 | 
					        <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920">
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					<ng-container *ngIf="{ val: network$ | async } as network">
 | 
				
			||||||
 | 
					<div class="preview-wrapper">
 | 
				
			||||||
 | 
					  <router-outlet></router-outlet>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <footer>
 | 
				
			||||||
 | 
					    <span class="footer-brand" style="position: relative;">
 | 
				
			||||||
 | 
					      <img *ngIf="!officialMempoolSpace" src="/resources/mempool-logo.png" height="35" width="140" class="logo">
 | 
				
			||||||
 | 
					      <app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 140px; height: 35px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div [ngSwitch]="network.val">
 | 
				
			||||||
 | 
					      <span *ngSwitchCase="'signet'" class="network signet"><img src="/resources/signet-logo.png" style="width: 30px;" class="signet mr-1" alt="logo"> Signet</span>
 | 
				
			||||||
 | 
					      <span *ngSwitchCase="'testnet'" class="network testnet"><img src="/resources/testnet-logo.png" style="width: 30px;" class="mr-1" alt="testnet logo"> Testnet</span>
 | 
				
			||||||
 | 
					      <span *ngSwitchCase="'bisq'" class="network bisq"><img src="/resources/bisq-logo.png" style="width: 30px;" class="mr-1" alt="bisq logo"> Bisq</span>
 | 
				
			||||||
 | 
					      <span *ngSwitchCase="'liquid'" class="network liquid"><img src="/resources/liquid-logo.png" style="width: 30px;" class="mr-1" alt="liquid mainnet logo"> Liquid</span>
 | 
				
			||||||
 | 
					      <span *ngSwitchCase="'liquidtestnet'" class="network liquidtestnet"><img src="/resources/liquidtestnet-logo.png" style="width: 30px;" class="mr-1" alt="liquid testnet logo"> Liquid Testnet</span>
 | 
				
			||||||
 | 
					      <span *ngSwitchDefault class="network mainnet"><img src="/resources/bitcoin-logo.png" style="width: 30px;" class="mainnet mr-1" alt="bitcoin logo"> Mainnet</span>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </footer>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					</ng-container>
 | 
				
			||||||
@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					.preview-wrapper {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					  margin: auto;
 | 
				
			||||||
 | 
					  max-width: 1024px;
 | 
				
			||||||
 | 
					  max-height: 512px;
 | 
				
			||||||
 | 
					  padding-bottom: 64px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  footer {
 | 
				
			||||||
 | 
					    position: absolute;
 | 
				
			||||||
 | 
					    left: 0;
 | 
				
			||||||
 | 
					    right: 0;
 | 
				
			||||||
 | 
					    bottom: 0;
 | 
				
			||||||
 | 
					    z-index: 100;
 | 
				
			||||||
 | 
					    min-height: 64px;
 | 
				
			||||||
 | 
					    padding: 0rem 2rem;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    justify-content: space-between;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    background: #11131f;
 | 
				
			||||||
 | 
					    text-align: start;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .footer-brand {
 | 
				
			||||||
 | 
					    width: 60%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .network {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    justify-content: flex-start;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,25 @@
 | 
				
			|||||||
 | 
					import { Component, OnInit } from '@angular/core';
 | 
				
			||||||
 | 
					import { StateService } from '../../services/state.service';
 | 
				
			||||||
 | 
					import { Observable, merge, of } from 'rxjs';
 | 
				
			||||||
 | 
					import { LanguageService } from 'src/app/services/language.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-master-page-preview',
 | 
				
			||||||
 | 
					  templateUrl: './master-page-preview.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./master-page-preview.component.scss'],
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class MasterPagePreviewComponent implements OnInit {
 | 
				
			||||||
 | 
					  network$: Observable<string>;
 | 
				
			||||||
 | 
					  officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE;
 | 
				
			||||||
 | 
					  urlLanguage: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    public stateService: StateService,
 | 
				
			||||||
 | 
					    private languageService: LanguageService,
 | 
				
			||||||
 | 
					  ) { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnInit() {
 | 
				
			||||||
 | 
					    this.network$ = merge(of(''), this.stateService.networkChanged$);
 | 
				
			||||||
 | 
					    this.urlLanguage = this.languageService.getLanguageForUrl();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,7 +1,12 @@
 | 
				
			|||||||
<ng-container *ngIf="{ val: network$ | async } as network">
 | 
					<ng-container *ngIf="{ val: network$ | async } as network">
 | 
				
			||||||
<header>
 | 
					<header>
 | 
				
			||||||
  <nav class="navbar navbar-expand-md navbar-dark bg-dark">
 | 
					  <nav class="navbar navbar-expand-md navbar-dark bg-dark">
 | 
				
			||||||
  <a class="navbar-brand" [routerLink]="['/' | relativeUrl]" style="position: relative;">
 | 
					  <a class="navbar-brand" [routerLink]="['/' | relativeUrl]">
 | 
				
			||||||
 | 
					  <ng-template [ngIf]="subdomain">
 | 
				
			||||||
 | 
					    <div class="subdomain_container">
 | 
				
			||||||
 | 
					      <img [src]="'/api/v1/enterprise/images/' + subdomain + '/logo'" class="subdomain_logo">
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </ng-template>
 | 
				
			||||||
    <ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
 | 
					    <ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
 | 
				
			||||||
      <img *ngIf="!officialMempoolSpace" src="/resources/mempool-logo.png" height="35" width="140" class="logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }" alt="The Mempool Open Source Project logo">
 | 
					      <img *ngIf="!officialMempoolSpace" src="/resources/mempool-logo.png" height="35" width="140" class="logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }" alt="The Mempool Open Source Project logo">
 | 
				
			||||||
      <app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 140px; height: 35px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
 | 
					      <app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 140px; height: 35px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
 | 
				
			||||||
@ -44,9 +49,6 @@
 | 
				
			|||||||
      <li class="nav-item" routerLinkActive="active" id="btn-graphs">
 | 
					      <li class="nav-item" routerLinkActive="active" id="btn-graphs">
 | 
				
			||||||
        <a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a>
 | 
					        <a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
      <li class="nav-item d-none d-lg-block" routerLinkActive="active" id="btn-tv">
 | 
					 | 
				
			||||||
        <a class="nav-link" [routerLink]="['/tv' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon></a>
 | 
					 | 
				
			||||||
      </li>
 | 
					 | 
				
			||||||
      <li class="nav-item" routerLinkActive="active" id="btn-docs">
 | 
					      <li class="nav-item" routerLinkActive="active" id="btn-docs">
 | 
				
			||||||
        <a class="nav-link" [routerLink]="['/docs' | relativeUrl ]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="documentation.title" title="Documentation"></fa-icon></a>
 | 
					        <a class="nav-link" [routerLink]="['/docs' | relativeUrl ]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="documentation.title" title="Documentation"></fa-icon></a>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
 | 
				
			|||||||
@ -68,10 +68,6 @@ li.nav-item {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.navbar-brand {
 | 
					 | 
				
			||||||
  width: 60%;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.navbar {
 | 
					.navbar {
 | 
				
			||||||
  .dropdown {
 | 
					  .dropdown {
 | 
				
			||||||
    .dropdown-toggle {
 | 
					    .dropdown-toggle {
 | 
				
			||||||
@ -80,10 +76,8 @@ li.nav-item {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@media (min-width: 576px) {
 | 
					 | 
				
			||||||
.navbar-brand {
 | 
					.navbar-brand {
 | 
				
			||||||
    width: 140px;
 | 
					  position: relative;
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
nav {
 | 
					nav {
 | 
				
			||||||
@ -93,8 +87,7 @@ nav {
 | 
				
			|||||||
.connection-badge {
 | 
					.connection-badge {
 | 
				
			||||||
  position: absolute;
 | 
					  position: absolute;
 | 
				
			||||||
  top: 13px;
 | 
					  top: 13px;
 | 
				
			||||||
  left: 0px;
 | 
					  width: 100%;
 | 
				
			||||||
  width: 140px;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.badge {
 | 
					.badge {
 | 
				
			||||||
@ -145,3 +138,26 @@ nav {
 | 
				
			|||||||
.navbar-dark .navbar-nav .nav-link {
 | 
					.navbar-dark .navbar-nav .nav-link {
 | 
				
			||||||
  color: #f1f1f1;
 | 
					  color: #f1f1f1;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.subdomain_logo {
 | 
				
			||||||
 | 
					  max-height: 45px;
 | 
				
			||||||
 | 
					  max-width: 140px;
 | 
				
			||||||
 | 
					  margin: auto;
 | 
				
			||||||
 | 
					  align-self: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.subdomain_container {
 | 
				
			||||||
 | 
					  width: 140px;
 | 
				
			||||||
 | 
					  margin-right: 15px;
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.logo-holder {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: row;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.navbar-brand {
 | 
				
			||||||
 | 
					  flex-direction: row;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,8 @@
 | 
				
			|||||||
import { Component, OnInit } from '@angular/core';
 | 
					import { Component, Inject, OnInit } from '@angular/core';
 | 
				
			||||||
import { Env, StateService } from '../../services/state.service';
 | 
					import { Env, StateService } from '../../services/state.service';
 | 
				
			||||||
import { Observable, merge, of } from 'rxjs';
 | 
					import { Observable, merge, of } from 'rxjs';
 | 
				
			||||||
import { LanguageService } from 'src/app/services/language.service';
 | 
					import { LanguageService } from 'src/app/services/language.service';
 | 
				
			||||||
 | 
					import { EnterpriseService } from 'src/app/services/enterprise.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Component({
 | 
					@Component({
 | 
				
			||||||
  selector: 'app-master-page',
 | 
					  selector: 'app-master-page',
 | 
				
			||||||
@ -16,10 +17,12 @@ export class MasterPageComponent implements OnInit {
 | 
				
			|||||||
  isMobile = window.innerWidth <= 767.98;
 | 
					  isMobile = window.innerWidth <= 767.98;
 | 
				
			||||||
  officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE;
 | 
					  officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE;
 | 
				
			||||||
  urlLanguage: string;
 | 
					  urlLanguage: string;
 | 
				
			||||||
 | 
					  subdomain = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    public stateService: StateService,
 | 
					    public stateService: StateService,
 | 
				
			||||||
    private languageService: LanguageService,
 | 
					    private languageService: LanguageService,
 | 
				
			||||||
 | 
					    private enterpriseService: EnterpriseService,
 | 
				
			||||||
  ) { }
 | 
					  ) { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ngOnInit() {
 | 
					  ngOnInit() {
 | 
				
			||||||
@ -27,6 +30,7 @@ export class MasterPageComponent implements OnInit {
 | 
				
			|||||||
    this.connectionState$ = this.stateService.connectionState$;
 | 
					    this.connectionState$ = this.stateService.connectionState$;
 | 
				
			||||||
    this.network$ = merge(of(''), this.stateService.networkChanged$);
 | 
					    this.network$ = merge(of(''), this.stateService.networkChanged$);
 | 
				
			||||||
    this.urlLanguage = this.languageService.getLanguageForUrl();
 | 
					    this.urlLanguage = this.languageService.getLanguageForUrl();
 | 
				
			||||||
 | 
					    this.subdomain = this.enterpriseService.getSubdomain();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  collapse(): void {
 | 
					  collapse(): void {
 | 
				
			||||||
 | 
				
			|||||||
@ -32,10 +32,12 @@
 | 
				
			|||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="card-header" *ngIf="!widget">
 | 
					  <div class="card-header" *ngIf="!widget">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
      <span i18n="mining.pools">Pools Ranking</span>
 | 
					      <span i18n="mining.pools">Pools Ranking</span>
 | 
				
			||||||
    <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>  
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup"
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup"
 | 
				
			||||||
      *ngIf="!widget && (miningStatsObservable$ | async) as stats">
 | 
					      *ngIf="!widget && (miningStatsObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
 | 
				
			|||||||
@ -27,15 +27,7 @@ $width: 500;
 | 
				
			|||||||
$height: 500;
 | 
					$height: 500;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Create the explosion...
 | 
					// Create the explosion...
 | 
				
			||||||
$box-shadow: ();
 | 
					
 | 
				
			||||||
$box-shadow2: ();
 | 
					 | 
				
			||||||
@for $i from 0 through $particles {
 | 
					 | 
				
			||||||
  $box-shadow: $box-shadow,
 | 
					 | 
				
			||||||
               random($width) - math.div($width, 1.2) + px
 | 
					 | 
				
			||||||
               random($height) - math.div($height, 1.2) + px
 | 
					 | 
				
			||||||
               hsl(random(360), 100%, 50%);
 | 
					 | 
				
			||||||
  $box-shadow2: $box-shadow2, 0 0 #fff
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@mixin keyframes ($animationName) {
 | 
					@mixin keyframes ($animationName) {
 | 
				
			||||||
    @-webkit-keyframes #{$animationName} {
 | 
					    @-webkit-keyframes #{$animationName} {
 | 
				
			||||||
        @content;
 | 
					        @content;
 | 
				
			||||||
@ -103,7 +95,6 @@ body {
 | 
				
			|||||||
  width: 5px;
 | 
					  width: 5px;
 | 
				
			||||||
  height: 5px;
 | 
					  height: 5px;
 | 
				
			||||||
  border-radius: 50%;
 | 
					  border-radius: 50%;
 | 
				
			||||||
  box-shadow: $box-shadow2;
 | 
					 | 
				
			||||||
  @include animation((1s bang ease-out infinite backwards, 1s gravity ease-in infinite backwards, 5s position linear infinite backwards));
 | 
					  @include animation((1s bang ease-out infinite backwards, 1s gravity ease-in infinite backwards, 5s position linear infinite backwards));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -112,9 +103,9 @@ body {
 | 
				
			|||||||
  @include animation-duration((1.25s, 1.25s, 6.25s));
 | 
					  @include animation-duration((1.25s, 1.25s, 6.25s));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@include keyframes(bang) {
 | 
					@keyframes bang{
 | 
				
			||||||
  to{
 | 
					  to{
 | 
				
			||||||
    box-shadow:$box-shadow;
 | 
					      box-shadow:-314.6666666667px -362.6666666667px red,-51.6666666667px 32.3333333333px #ff3700,-354.6666666667px -264.6666666667px #7b00ff,-319.6666666667px -73.6666666667px #00f7ff,-135.6666666667px -154.6666666667px #00ff48,57.3333333333px -402.6666666667px #0d00ff,-126.6666666667px -121.6666666667px #00ff7b,-335.6666666667px -5.6666666667px #00fff2,-291.6666666667px -.6666666667px #4f0,-126.6666666667px -187.6666666667px #7f0,-413.6666666667px -224.6666666667px #00ffbf,-283.6666666667px -391.6666666667px #00ff3c,-340.6666666667px -345.6666666667px #02f,-168.6666666667px -179.6666666667px #eaff00,7.3333333333px -153.6666666667px #26ff00,-175.6666666667px -234.6666666667px #8400ff,-324.6666666667px -254.6666666667px #0048ff,-335.6666666667px -9.6666666667px #00ff59,-304.6666666667px -8.6666666667px #001eff,-331.6666666667px -44.6666666667px #3f0,.3333333333px -49.6666666667px #0fc,-370.6666666667px -60.6666666667px #0015ff,29.3333333333px -13.6666666667px #8cff00,-168.6666666667px -281.6666666667px #f80,-48.6666666667px -61.6666666667px #f0b,33.3333333333px -113.6666666667px #ff00e1,-193.6666666667px -196.6666666667px #ff7b00,-14.6666666667px -24.6666666667px #ff0037,-149.6666666667px -273.6666666667px #0fa,-19.6666666667px -63.6666666667px #ff0004,13.3333333333px -227.6666666667px #7f0,-265.6666666667px -43.6666666667px #ff4800,-121.6666666667px -95.6666666667px #bfff00,-241.6666666667px -90.6666666667px #6200ff,-307.6666666667px -231.6666666667px #ff0062,78.3333333333px -128.6666666667px #ffbf00,27.3333333333px 44.3333333333px #95ff00,-81.6666666667px 6.3333333333px #ffc800,-343.6666666667px -247.6666666667px #2f0,-225.6666666667px -250.6666666667px #08f,-9.6666666667px -243.6666666667px #ff1a00,83.3333333333px -409.6666666667px #04f,-380.6666666667px -331.6666666667px #84ff00,-103.6666666667px -51.6666666667px #f02,-174.6666666667px -169.6666666667px #ffc800,20.3333333333px -191.6666666667px #ff0059,-40.6666666667px -55.6666666667px #0400ff,-199.6666666667px -66.6666666667px #ffd500,-358.6666666667px -5.6666666667px #0051ff,-84.6666666667px -289.6666666667px #f7ff00,-193.6666666667px -184.6666666667px #80f
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -3,14 +3,22 @@
 | 
				
			|||||||
    <div>
 | 
					    <div>
 | 
				
			||||||
      <div class="card mb-3">
 | 
					      <div class="card mb-3">
 | 
				
			||||||
        <div class="card-header">
 | 
					        <div class="card-header">
 | 
				
			||||||
          <i class="fa fa-area-chart"></i>
 | 
					          <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
            <span i18n="statistics.memory-by-vBytes">Mempool by vBytes (sat/vByte)</span>
 | 
					            <span i18n="statistics.memory-by-vBytes">Mempool by vBytes (sat/vByte)</span>
 | 
				
			||||||
          <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart('mempool')">
 | 
					            <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart('mempool')">
 | 
				
			||||||
              <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					              <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
            </button>
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <form [formGroup]="radioGroupForm" class="formRadioGroup"
 | 
					          <form [formGroup]="radioGroupForm" class="formRadioGroup"
 | 
				
			||||||
            [class]="stateService.env.MINING_DASHBOARD ? 'mining' : ''" (click)="saveGraphPreference()">
 | 
					            [class]="stateService.env.MINING_DASHBOARD ? 'mining' : ''" (click)="saveGraphPreference()">
 | 
				
			||||||
 | 
					            <div *ngIf="!isMobile()" class="btn-group btn-group-toggle">
 | 
				
			||||||
 | 
					              <label ngbButtonLabel class="btn-primary btn-sm mr-2">
 | 
				
			||||||
 | 
					                <a [routerLink]="['/tv' | relativeUrl]" style="color: white" id="btn-tv">
 | 
				
			||||||
 | 
					                  <fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon>
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					                </label>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
            <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					            <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
              <label ngbButtonLabel class="btn-primary btn-sm">
 | 
					              <label ngbButtonLabel class="btn-primary btn-sm">
 | 
				
			||||||
                <input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h"> 2H
 | 
					                <input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h"> 2H
 | 
				
			||||||
@ -84,12 +92,13 @@
 | 
				
			|||||||
    <div>
 | 
					    <div>
 | 
				
			||||||
      <div class="card mb-3">
 | 
					      <div class="card mb-3">
 | 
				
			||||||
        <div class="card-header">
 | 
					        <div class="card-header">
 | 
				
			||||||
          <i class="fa fa-area-chart"></i>
 | 
					          <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
            <span i18n="statistics.transaction-vbytes-per-second">Transaction vBytes per second (vB/s)</span>
 | 
					            <span i18n="statistics.transaction-vbytes-per-second">Transaction vBytes per second (vB/s)</span>
 | 
				
			||||||
          <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart('incoming')">
 | 
					            <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart('incoming')">
 | 
				
			||||||
              <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					              <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
            </button>
 | 
					            </button>
 | 
				
			||||||
          </div>  
 | 
					          </div>  
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div class="card-body">
 | 
					        <div class="card-body">
 | 
				
			||||||
          <div class="incoming-transactions-graph">
 | 
					          <div class="incoming-transactions-graph">
 | 
				
			||||||
 | 
				
			|||||||
@ -210,4 +210,8 @@ export class StatisticsComponent implements OnInit {
 | 
				
			|||||||
      this.incomingGraph.onSaveChart(this.timespan);
 | 
					      this.incomingGraph.onSaveChart(this.timespan);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  isMobile() {
 | 
				
			||||||
 | 
					    return (window.innerWidth <= 767.98);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -5,6 +5,9 @@
 | 
				
			|||||||
    <span *ngIf="segwitGains.potentialP2shGains" class="badge badge-danger mr-1" i18n-ngbTooltip="ngbTooltip about missed out gains" ngbTooltip="This transaction could save {{ segwitGains.potentialBech32Gains * 100 | number : '1.0-0' }}% on fees by upgrading to native SegWit-Bech32 or {{ segwitGains.potentialP2shGains * 100 | number:  '1.0-0' }}% by upgrading to SegWit-P2SH" placement="bottom"><del i18n="tx-features.tag.segwit|SegWit">SegWit</del></span>
 | 
					    <span *ngIf="segwitGains.potentialP2shGains" class="badge badge-danger mr-1" i18n-ngbTooltip="ngbTooltip about missed out gains" ngbTooltip="This transaction could save {{ segwitGains.potentialBech32Gains * 100 | number : '1.0-0' }}% on fees by upgrading to native SegWit-Bech32 or {{ segwitGains.potentialP2shGains * 100 | number:  '1.0-0' }}% by upgrading to SegWit-P2SH" placement="bottom"><del i18n="tx-features.tag.segwit|SegWit">SegWit</del></span>
 | 
				
			||||||
  </ng-template>
 | 
					  </ng-template>
 | 
				
			||||||
</ng-template>
 | 
					</ng-template>
 | 
				
			||||||
<span *ngIf="isTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Taproot tooltip" ngbTooltip="This transaction uses Taproot" placement="bottom" i18n="tx-features.tag.taproot">Taproot</span>
 | 
					<span *ngIf="isTaproot; else noTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Taproot tooltip" ngbTooltip="This transaction uses Taproot" placement="bottom" i18n="tx-features.tag.taproot">Taproot</span>
 | 
				
			||||||
 | 
					<ng-template #noTaproot>
 | 
				
			||||||
 | 
					  <span class="badge badge-danger mr-1" i18n-ngbTooltip="No Taproot tooltip" ngbTooltip="This transaction could save on fees and improve privacy by using Taproot" placement="bottom"><del i18n="tx-features.tag.taproot">Taproot</del></span>
 | 
				
			||||||
 | 
					</ng-template>
 | 
				
			||||||
<span *ngIf="isRbfTransaction; else rbfDisabled" class="badge badge-success" i18n-ngbTooltip="RBF tooltip" ngbTooltip="This transaction support Replace-By-Fee (RBF) allowing fee bumping" placement="bottom" i18n="tx-features.tag.rbf|RBF">RBF</span>
 | 
					<span *ngIf="isRbfTransaction; else rbfDisabled" class="badge badge-success" i18n-ngbTooltip="RBF tooltip" ngbTooltip="This transaction support Replace-By-Fee (RBF) allowing fee bumping" placement="bottom" i18n="tx-features.tag.rbf|RBF">RBF</span>
 | 
				
			||||||
<ng-template #rbfDisabled><span class="badge badge-danger mr-1" i18n-ngbTooltip="RBF disabled tooltip" ngbTooltip="This transaction does NOT support Replace-By-Fee (RBF) and cannot be fee bumped using this method" placement="bottom"><del i18n="tx-features.tag.rbf|RBF">RBF</del></span></ng-template>
 | 
					<ng-template #rbfDisabled><span class="badge badge-danger mr-1" i18n-ngbTooltip="RBF disabled tooltip" ngbTooltip="This transaction does NOT support Replace-By-Fee (RBF) and cannot be fee bumped using this method" placement="bottom"><del i18n="tx-features.tag.rbf|RBF">RBF</del></span></ng-template>
 | 
				
			||||||
 | 
				
			|||||||
@ -20,6 +20,10 @@ import { TelevisionComponent } from '../components/television/television.compone
 | 
				
			|||||||
import { DashboardComponent } from '../dashboard/dashboard.component';
 | 
					import { DashboardComponent } from '../dashboard/dashboard.component';
 | 
				
			||||||
import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
 | 
					import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
 | 
				
			||||||
import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component';
 | 
					import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component';
 | 
				
			||||||
 | 
					import { NodesPerISPChartComponent } from '../lightning/nodes-per-isp-chart/nodes-per-isp-chart.component';
 | 
				
			||||||
 | 
					import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
 | 
				
			||||||
 | 
					import { NodesMap } from '../lightning/nodes-map/nodes-map.component';
 | 
				
			||||||
 | 
					import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels-map.component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const browserWindow = window || {};
 | 
					const browserWindow = window || {};
 | 
				
			||||||
// @ts-ignore
 | 
					// @ts-ignore
 | 
				
			||||||
@ -99,6 +103,22 @@ const routes: Routes = [
 | 
				
			|||||||
            path: 'lightning/capacity',
 | 
					            path: 'lightning/capacity',
 | 
				
			||||||
            component: LightningStatisticsChartComponent,
 | 
					            component: LightningStatisticsChartComponent,
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            path: 'lightning/nodes-per-isp',
 | 
				
			||||||
 | 
					            component: NodesPerISPChartComponent,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            path: 'lightning/nodes-per-country',
 | 
				
			||||||
 | 
					            component: NodesPerCountryChartComponent,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            path: 'lightning/nodes-map',
 | 
				
			||||||
 | 
					            component: NodesMap,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            path: 'lightning/nodes-channels-map',
 | 
				
			||||||
 | 
					            component: NodesChannelsMap,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            path: '',
 | 
					            path: '',
 | 
				
			||||||
            redirectTo: 'mempool',
 | 
					            redirectTo: 'mempool',
 | 
				
			||||||
 | 
				
			|||||||
@ -128,11 +128,20 @@ export interface BlockExtended extends Block {
 | 
				
			|||||||
  extras?: BlockExtension;
 | 
					  extras?: BlockExtension;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface BlockAudit extends BlockExtended {
 | 
				
			||||||
 | 
					  missingTxs: string[],
 | 
				
			||||||
 | 
					  addedTxs: string[],
 | 
				
			||||||
 | 
					  matchRate: number,
 | 
				
			||||||
 | 
					  template: TransactionStripped[],
 | 
				
			||||||
 | 
					  transactions: TransactionStripped[],
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface TransactionStripped {
 | 
					export interface TransactionStripped {
 | 
				
			||||||
  txid: string;
 | 
					  txid: string;
 | 
				
			||||||
  fee: number;
 | 
					  fee: number;
 | 
				
			||||||
  vsize: number;
 | 
					  vsize: number;
 | 
				
			||||||
  value: number;
 | 
					  value: number;
 | 
				
			||||||
 | 
					  status?: 'found' | 'missing' | 'added';
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface RewardStats {
 | 
					export interface RewardStats {
 | 
				
			||||||
 | 
				
			|||||||
@ -70,6 +70,7 @@ export interface TransactionStripped {
 | 
				
			|||||||
  fee: number;
 | 
					  fee: number;
 | 
				
			||||||
  vsize: number;
 | 
					  vsize: number;
 | 
				
			||||||
  value: number;
 | 
					  value: number;
 | 
				
			||||||
 | 
					  status?: 'found' | 'missing' | 'added';
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface IBackendInfo {
 | 
					export interface IBackendInfo {
 | 
				
			||||||
 | 
				
			|||||||
@ -18,6 +18,12 @@ import { NodeStatisticsChartComponent } from './node-statistics-chart/node-stati
 | 
				
			|||||||
import { GraphsModule } from '../graphs/graphs.module';
 | 
					import { GraphsModule } from '../graphs/graphs.module';
 | 
				
			||||||
import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
 | 
					import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
 | 
				
			||||||
import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
 | 
					import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
 | 
				
			||||||
 | 
					import { NodesPerISPChartComponent } from './nodes-per-isp-chart/nodes-per-isp-chart.component';
 | 
				
			||||||
 | 
					import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
 | 
				
			||||||
 | 
					import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
 | 
				
			||||||
 | 
					import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
 | 
				
			||||||
 | 
					import { NodesMap } from '../lightning/nodes-map/nodes-map.component';
 | 
				
			||||||
 | 
					import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels-map.component';
 | 
				
			||||||
@NgModule({
 | 
					@NgModule({
 | 
				
			||||||
  declarations: [
 | 
					  declarations: [
 | 
				
			||||||
    LightningDashboardComponent,
 | 
					    LightningDashboardComponent,
 | 
				
			||||||
@ -33,6 +39,12 @@ import { ChannelsStatisticsComponent } from './channels-statistics/channels-stat
 | 
				
			|||||||
    LightningStatisticsChartComponent,
 | 
					    LightningStatisticsChartComponent,
 | 
				
			||||||
    NodesNetworksChartComponent,
 | 
					    NodesNetworksChartComponent,
 | 
				
			||||||
    ChannelsStatisticsComponent,
 | 
					    ChannelsStatisticsComponent,
 | 
				
			||||||
 | 
					    NodesPerISPChartComponent,
 | 
				
			||||||
 | 
					    NodesPerCountry,
 | 
				
			||||||
 | 
					    NodesPerISP,
 | 
				
			||||||
 | 
					    NodesPerCountryChartComponent,
 | 
				
			||||||
 | 
					    NodesMap,
 | 
				
			||||||
 | 
					    NodesChannelsMap,
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  imports: [
 | 
					  imports: [
 | 
				
			||||||
    CommonModule,
 | 
					    CommonModule,
 | 
				
			||||||
 | 
				
			|||||||
@ -4,6 +4,8 @@ import { LightningDashboardComponent } from './lightning-dashboard/lightning-das
 | 
				
			|||||||
import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
 | 
					import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
 | 
				
			||||||
import { NodeComponent } from './node/node.component';
 | 
					import { NodeComponent } from './node/node.component';
 | 
				
			||||||
import { ChannelComponent } from './channel/channel.component';
 | 
					import { ChannelComponent } from './channel/channel.component';
 | 
				
			||||||
 | 
					import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
 | 
				
			||||||
 | 
					import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const routes: Routes = [
 | 
					const routes: Routes = [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@ -22,6 +24,14 @@ const routes: Routes = [
 | 
				
			|||||||
          path: 'channel/:short_id',
 | 
					          path: 'channel/:short_id',
 | 
				
			||||||
          component: ChannelComponent,
 | 
					          component: ChannelComponent,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          path: 'nodes/country/:country',
 | 
				
			||||||
 | 
					          component: NodesPerCountry,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          path: 'nodes/isp/:isp',
 | 
				
			||||||
 | 
					          component: NodesPerISP,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          path: '**',
 | 
					          path: '**',
 | 
				
			||||||
          redirectTo: ''
 | 
					          redirectTo: ''
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					<div class="full-container">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="card-header">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
 | 
				
			||||||
 | 
					      <span i18n="lightning.nodes-channels-world-map">Lightning nodes channels world map</span>
 | 
				
			||||||
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px">
 | 
				
			||||||
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true" (click)="onSaveChart()"></fa-icon>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div *ngIf="observable$ | async" class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
 | 
				
			||||||
 | 
					    (chartInit)="onChartInit($event)">
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					.card-header {
 | 
				
			||||||
 | 
					  border-bottom: 0;
 | 
				
			||||||
 | 
					  font-size: 18px;
 | 
				
			||||||
 | 
					  @media (min-width: 465px) {
 | 
				
			||||||
 | 
					    font-size: 20px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.full-container {
 | 
				
			||||||
 | 
					  padding: 0px 15px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  min-height: 500px;
 | 
				
			||||||
 | 
					  height: calc(100% - 150px);
 | 
				
			||||||
 | 
					  @media (max-width: 992px) {
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					    padding-bottom: 100px;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.chart {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					  padding-bottom: 20px;
 | 
				
			||||||
 | 
					  padding-right: 10px;
 | 
				
			||||||
 | 
					  @media (max-width: 992px) {
 | 
				
			||||||
 | 
					    padding-bottom: 25px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  @media (max-width: 829px) {
 | 
				
			||||||
 | 
					    padding-bottom: 50px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  @media (max-width: 767px) {
 | 
				
			||||||
 | 
					    padding-bottom: 25px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  @media (max-width: 629px) {
 | 
				
			||||||
 | 
					    padding-bottom: 55px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  @media (max-width: 567px) {
 | 
				
			||||||
 | 
					    padding-bottom: 55px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,189 @@
 | 
				
			|||||||
 | 
					import { ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit } from '@angular/core';
 | 
				
			||||||
 | 
					import { SeoService } from 'src/app/services/seo.service';
 | 
				
			||||||
 | 
					import { ApiService } from 'src/app/services/api.service';
 | 
				
			||||||
 | 
					import { Observable, tap, zip } from 'rxjs';
 | 
				
			||||||
 | 
					import { AssetsService } from 'src/app/services/assets.service';
 | 
				
			||||||
 | 
					import { download } from 'src/app/shared/graphs.utils';
 | 
				
			||||||
 | 
					import { Router } from '@angular/router';
 | 
				
			||||||
 | 
					import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
 | 
				
			||||||
 | 
					import { StateService } from 'src/app/services/state.service';
 | 
				
			||||||
 | 
					import { EChartsOption, registerMap } from 'echarts';
 | 
				
			||||||
 | 
					import 'echarts-gl';
 | 
				
			||||||
 | 
					import { SSL_OP_SSLEAY_080_CLIENT_DH_BUG } from 'constants';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-nodes-channels-map',
 | 
				
			||||||
 | 
					  templateUrl: './nodes-channels-map.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./nodes-channels-map.component.scss'],
 | 
				
			||||||
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class NodesChannelsMap implements OnInit, OnDestroy {
 | 
				
			||||||
 | 
					  observable$: Observable<any>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  chartInstance = undefined;
 | 
				
			||||||
 | 
					  chartOptions: EChartsOption = {color: 'dark'};
 | 
				
			||||||
 | 
					  chartInitOptions = {
 | 
				
			||||||
 | 
					    renderer: 'canvas',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private seoService: SeoService,
 | 
				
			||||||
 | 
					    private apiService: ApiService,
 | 
				
			||||||
 | 
					    private stateService: StateService,
 | 
				
			||||||
 | 
					    private assetsService: AssetsService,
 | 
				
			||||||
 | 
					    private router: Router,
 | 
				
			||||||
 | 
					    private zone: NgZone,
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnDestroy(): void {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnInit(): void {
 | 
				
			||||||
 | 
					    this.seoService.setTitle($localize`Lightning nodes channels world map`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.observable$ = zip(
 | 
				
			||||||
 | 
					      this.assetsService.getWorldMapJson$,
 | 
				
			||||||
 | 
					      this.apiService.getChannelsGeo$(),
 | 
				
			||||||
 | 
					    ).pipe(tap((data) => {
 | 
				
			||||||
 | 
					      registerMap('world', data[0]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const channelsLoc = [];
 | 
				
			||||||
 | 
					      const nodes = [];
 | 
				
			||||||
 | 
					      for (const channel of data[1]) {
 | 
				
			||||||
 | 
					        channelsLoc.push([[channel[2], channel[3]], [channel[6], channel[7]]]);
 | 
				
			||||||
 | 
					        nodes.push({
 | 
				
			||||||
 | 
					          publicKey: channel[0],
 | 
				
			||||||
 | 
					          name: channel[1],
 | 
				
			||||||
 | 
					          value: [channel[2], channel[3]],
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        nodes.push({
 | 
				
			||||||
 | 
					          publicKey: channel[4],
 | 
				
			||||||
 | 
					          name: channel[5],
 | 
				
			||||||
 | 
					          value: [channel[6], channel[7]],
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.prepareChartOptions(nodes, channelsLoc);
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  prepareChartOptions(nodes, channels) {
 | 
				
			||||||
 | 
					    let title: object;
 | 
				
			||||||
 | 
					    if (channels.length === 0) {
 | 
				
			||||||
 | 
					      title = {
 | 
				
			||||||
 | 
					        textStyle: {
 | 
				
			||||||
 | 
					          color: 'grey',
 | 
				
			||||||
 | 
					          fontSize: 15
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        text: $localize`No data to display yet`,
 | 
				
			||||||
 | 
					        left: 'center',
 | 
				
			||||||
 | 
					        top: 'center'
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartOptions = {
 | 
				
			||||||
 | 
					      geo3D: {
 | 
				
			||||||
 | 
					        map: 'world',
 | 
				
			||||||
 | 
					        shading: 'color',
 | 
				
			||||||
 | 
					        silent: true,
 | 
				
			||||||
 | 
					        postEffect: {
 | 
				
			||||||
 | 
					          enable: true,
 | 
				
			||||||
 | 
					          bloom: {
 | 
				
			||||||
 | 
					            intensity: 0.01,
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        viewControl: {
 | 
				
			||||||
 | 
					          minDistance: 1,
 | 
				
			||||||
 | 
					          distance: 60,
 | 
				
			||||||
 | 
					          alpha: 89,
 | 
				
			||||||
 | 
					          panMouseButton: 'left',
 | 
				
			||||||
 | 
					          rotateMouseButton: 'right',
 | 
				
			||||||
 | 
					          zoomSensivity: 0.5,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        itemStyle: {
 | 
				
			||||||
 | 
					          color: '#FFFFFF',
 | 
				
			||||||
 | 
					          opacity: 0.02,
 | 
				
			||||||
 | 
					          borderWidth: 1,
 | 
				
			||||||
 | 
					          borderColor: 'black',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        regionHeight: 0.01,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      series: [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          // @ts-ignore
 | 
				
			||||||
 | 
					          type: 'lines3D',
 | 
				
			||||||
 | 
					          coordinateSystem: 'geo3D',
 | 
				
			||||||
 | 
					          blendMode: 'lighter',
 | 
				
			||||||
 | 
					          lineStyle: {
 | 
				
			||||||
 | 
					            width: 1,
 | 
				
			||||||
 | 
					            opacity: 0.025,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          data: channels
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          // @ts-ignore
 | 
				
			||||||
 | 
					          type: 'scatter3D',
 | 
				
			||||||
 | 
					          symbol: 'circle',
 | 
				
			||||||
 | 
					          blendMode: 'lighter',
 | 
				
			||||||
 | 
					          coordinateSystem: 'geo3D',
 | 
				
			||||||
 | 
					          symbolSize: 3,
 | 
				
			||||||
 | 
					          itemStyle: {
 | 
				
			||||||
 | 
					            color: '#BBFFFF',
 | 
				
			||||||
 | 
					            opacity: 1,
 | 
				
			||||||
 | 
					            borderColor: '#FFFFFF00',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          data: nodes,
 | 
				
			||||||
 | 
					          emphasis: {
 | 
				
			||||||
 | 
					            label: {
 | 
				
			||||||
 | 
					              position: 'top',
 | 
				
			||||||
 | 
					              // @ts-ignore
 | 
				
			||||||
 | 
					              textStyle: {
 | 
				
			||||||
 | 
					                color: 'white',
 | 
				
			||||||
 | 
					                fontSize: 16,
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              formatter: function(value) {
 | 
				
			||||||
 | 
					                return value.name;
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              show: true,
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onChartInit(ec) {
 | 
				
			||||||
 | 
					    if (this.chartInstance !== undefined) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartInstance = ec;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartInstance.on('click', (e) => {
 | 
				
			||||||
 | 
					      if (e.data && e.data.publicKey) {
 | 
				
			||||||
 | 
					        this.zone.run(() => {
 | 
				
			||||||
 | 
					          const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/node/${e.data.publicKey}`);
 | 
				
			||||||
 | 
					          this.router.navigate([url]);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onSaveChart() {
 | 
				
			||||||
 | 
					    // @ts-ignore
 | 
				
			||||||
 | 
					    const prevBottom = this.chartOptions.grid.bottom;
 | 
				
			||||||
 | 
					    const now = new Date();
 | 
				
			||||||
 | 
					    // @ts-ignore
 | 
				
			||||||
 | 
					    this.chartOptions.grid.bottom = 30;
 | 
				
			||||||
 | 
					    this.chartOptions.backgroundColor = '#11131f';
 | 
				
			||||||
 | 
					    this.chartInstance.setOption(this.chartOptions);
 | 
				
			||||||
 | 
					    download(this.chartInstance.getDataURL({
 | 
				
			||||||
 | 
					      pixelRatio: 2,
 | 
				
			||||||
 | 
					      excludeComponents: ['dataZoom'],
 | 
				
			||||||
 | 
					    }), `lightning-nodes-heatmap-clearnet-${Math.round(now.getTime() / 1000)}.svg`);
 | 
				
			||||||
 | 
					    // @ts-ignore
 | 
				
			||||||
 | 
					    this.chartOptions.grid.bottom = prevBottom;
 | 
				
			||||||
 | 
					    this.chartOptions.backgroundColor = 'none';
 | 
				
			||||||
 | 
					    this.chartInstance.setOption(this.chartOptions);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					<div class="full-container">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="card-header">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
 | 
				
			||||||
 | 
					      <span i18n="lightning.nodes-heatmap">Lightning nodes world heat map</span>
 | 
				
			||||||
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px">
 | 
				
			||||||
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true" (click)="onSaveChart()"></fa-icon>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div *ngIf="observable$ | async" class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
 | 
				
			||||||
 | 
					    (chartInit)="onChartInit($event)">
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					.card-header {
 | 
				
			||||||
 | 
					  border-bottom: 0;
 | 
				
			||||||
 | 
					  font-size: 18px;
 | 
				
			||||||
 | 
					  @media (min-width: 465px) {
 | 
				
			||||||
 | 
					    font-size: 20px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.full-container {
 | 
				
			||||||
 | 
					  padding: 0px 15px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  min-height: 500px;
 | 
				
			||||||
 | 
					  height: calc(100% - 150px);
 | 
				
			||||||
 | 
					  @media (max-width: 992px) {
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					    padding-bottom: 100px;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.chart {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					  padding-bottom: 20px;
 | 
				
			||||||
 | 
					  padding-right: 10px;
 | 
				
			||||||
 | 
					  @media (max-width: 992px) {
 | 
				
			||||||
 | 
					    padding-bottom: 25px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  @media (max-width: 829px) {
 | 
				
			||||||
 | 
					    padding-bottom: 50px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  @media (max-width: 767px) {
 | 
				
			||||||
 | 
					    padding-bottom: 25px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  @media (max-width: 629px) {
 | 
				
			||||||
 | 
					    padding-bottom: 55px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  @media (max-width: 567px) {
 | 
				
			||||||
 | 
					    padding-bottom: 55px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										163
									
								
								frontend/src/app/lightning/nodes-map/nodes-map.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								frontend/src/app/lightning/nodes-map/nodes-map.component.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,163 @@
 | 
				
			|||||||
 | 
					import { ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit } from '@angular/core';
 | 
				
			||||||
 | 
					import { mempoolFeeColors } from 'src/app/app.constants';
 | 
				
			||||||
 | 
					import { SeoService } from 'src/app/services/seo.service';
 | 
				
			||||||
 | 
					import { ApiService } from 'src/app/services/api.service';
 | 
				
			||||||
 | 
					import { combineLatest, Observable, tap } from 'rxjs';
 | 
				
			||||||
 | 
					import { AssetsService } from 'src/app/services/assets.service';
 | 
				
			||||||
 | 
					import { EChartsOption, registerMap } from 'echarts';
 | 
				
			||||||
 | 
					import { download } from 'src/app/shared/graphs.utils';
 | 
				
			||||||
 | 
					import { Router } from '@angular/router';
 | 
				
			||||||
 | 
					import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
 | 
				
			||||||
 | 
					import { StateService } from 'src/app/services/state.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-nodes-map',
 | 
				
			||||||
 | 
					  templateUrl: './nodes-map.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./nodes-map.component.scss'],
 | 
				
			||||||
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class NodesMap implements OnInit, OnDestroy {
 | 
				
			||||||
 | 
					  observable$: Observable<any>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  chartInstance = undefined;
 | 
				
			||||||
 | 
					  chartOptions: EChartsOption = {};
 | 
				
			||||||
 | 
					  chartInitOptions = {
 | 
				
			||||||
 | 
					    renderer: 'svg',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private seoService: SeoService,
 | 
				
			||||||
 | 
					    private apiService: ApiService,
 | 
				
			||||||
 | 
					    private stateService: StateService,
 | 
				
			||||||
 | 
					    private assetsService: AssetsService,
 | 
				
			||||||
 | 
					    private router: Router,
 | 
				
			||||||
 | 
					    private zone: NgZone,
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnDestroy(): void {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnInit(): void {
 | 
				
			||||||
 | 
					    this.seoService.setTitle($localize`Lightning nodes world map`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.observable$ = combineLatest([
 | 
				
			||||||
 | 
					      this.assetsService.getWorldMapJson$,
 | 
				
			||||||
 | 
					      this.apiService.getNodesPerCountry()
 | 
				
			||||||
 | 
					    ]).pipe(tap((data) => {
 | 
				
			||||||
 | 
					      registerMap('world', data[0]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const countries = [];
 | 
				
			||||||
 | 
					      let max = 0;
 | 
				
			||||||
 | 
					      for (const country of data[1]) {
 | 
				
			||||||
 | 
					        countries.push({
 | 
				
			||||||
 | 
					          name: country.name.en,
 | 
				
			||||||
 | 
					          value: country.count,
 | 
				
			||||||
 | 
					          iso: country.iso.toLowerCase(),
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        max = Math.max(max, country.count);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.prepareChartOptions(countries, max);
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  prepareChartOptions(countries, max) {
 | 
				
			||||||
 | 
					    let title: object;
 | 
				
			||||||
 | 
					    if (countries.length === 0) {
 | 
				
			||||||
 | 
					      title = {
 | 
				
			||||||
 | 
					        textStyle: {
 | 
				
			||||||
 | 
					          color: 'grey',
 | 
				
			||||||
 | 
					          fontSize: 15
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        text: $localize`No data to display yet`,
 | 
				
			||||||
 | 
					        left: 'center',
 | 
				
			||||||
 | 
					        top: 'center'
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartOptions = {
 | 
				
			||||||
 | 
					      title: countries.length === 0 ? title : undefined,
 | 
				
			||||||
 | 
					      tooltip: {
 | 
				
			||||||
 | 
					        backgroundColor: 'rgba(17, 19, 31, 1)',
 | 
				
			||||||
 | 
					        borderRadius: 4,
 | 
				
			||||||
 | 
					        shadowColor: 'rgba(0, 0, 0, 0.5)',
 | 
				
			||||||
 | 
					        textStyle: {
 | 
				
			||||||
 | 
					          color: '#b1b1b1',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        borderColor: '#000',
 | 
				
			||||||
 | 
					        formatter: function(country) {
 | 
				
			||||||
 | 
					          if (country.data === undefined) { 
 | 
				
			||||||
 | 
					            return `<b style="color: white">${country.name}<br>0 nodes</b><br>`;
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            return `<b style="color: white">${country.data.name}<br>${country.data.value} nodes</b><br>`;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      visualMap: {
 | 
				
			||||||
 | 
					        left: 'right',
 | 
				
			||||||
 | 
					        show: true,
 | 
				
			||||||
 | 
					        min: 1,
 | 
				
			||||||
 | 
					        max: max,
 | 
				
			||||||
 | 
					        text: ['High', 'Low'],
 | 
				
			||||||
 | 
					        calculable: true,        
 | 
				
			||||||
 | 
					        textStyle: {
 | 
				
			||||||
 | 
					          color: 'white',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        inRange: {
 | 
				
			||||||
 | 
					          color: mempoolFeeColors.map(color => `#${color}`),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      series: {
 | 
				
			||||||
 | 
					        type: 'map',
 | 
				
			||||||
 | 
					        map: 'world',
 | 
				
			||||||
 | 
					        emphasis: {
 | 
				
			||||||
 | 
					          label: {
 | 
				
			||||||
 | 
					            show: false,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          itemStyle: {
 | 
				
			||||||
 | 
					            areaColor: '#FDD835',
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        data: countries,
 | 
				
			||||||
 | 
					        itemStyle: {
 | 
				
			||||||
 | 
					          areaColor: '#5A6A6D'
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onChartInit(ec) {
 | 
				
			||||||
 | 
					    if (this.chartInstance !== undefined) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartInstance = ec;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartInstance.on('click', (e) => {
 | 
				
			||||||
 | 
					      if (e.data && e.data.value > 0) {
 | 
				
			||||||
 | 
					        this.zone.run(() => {
 | 
				
			||||||
 | 
					          const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/country/${e.data.iso}`);
 | 
				
			||||||
 | 
					          this.router.navigate([url]);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onSaveChart() {
 | 
				
			||||||
 | 
					    // @ts-ignore
 | 
				
			||||||
 | 
					    const prevBottom = this.chartOptions.grid.bottom;
 | 
				
			||||||
 | 
					    const now = new Date();
 | 
				
			||||||
 | 
					    // @ts-ignore
 | 
				
			||||||
 | 
					    this.chartOptions.grid.bottom = 30;
 | 
				
			||||||
 | 
					    this.chartOptions.backgroundColor = '#11131f';
 | 
				
			||||||
 | 
					    this.chartInstance.setOption(this.chartOptions);
 | 
				
			||||||
 | 
					    download(this.chartInstance.getDataURL({
 | 
				
			||||||
 | 
					      pixelRatio: 2,
 | 
				
			||||||
 | 
					      excludeComponents: ['dataZoom'],
 | 
				
			||||||
 | 
					    }), `lightning-nodes-heatmap-clearnet-${Math.round(now.getTime() / 1000)}.svg`);
 | 
				
			||||||
 | 
					    // @ts-ignore
 | 
				
			||||||
 | 
					    this.chartOptions.grid.bottom = prevBottom;
 | 
				
			||||||
 | 
					    this.chartOptions.backgroundColor = 'none';
 | 
				
			||||||
 | 
					    this.chartInstance.setOption(this.chartOptions);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,10 +1,12 @@
 | 
				
			|||||||
<div [class]="widget === false ? 'full-container' : ''">
 | 
					<div [class]="widget === false ? 'full-container' : ''">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
 | 
					  <div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
 | 
				
			||||||
    <span i18n="mining.nodes-networks">Nodes count by network</span>
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
    <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <span i18n="lightning.nodes-networks">Lightning nodes per network</span>
 | 
				
			||||||
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>  
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(nodesNetworkObservable$ | async) as stats">
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(nodesNetworkObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
 | 
				
			|||||||
@ -61,7 +61,7 @@ export class NodesNetworksChartComponent implements OnInit {
 | 
				
			|||||||
    if (this.widget) {
 | 
					    if (this.widget) {
 | 
				
			||||||
      this.miningWindowPreference = '1y';
 | 
					      this.miningWindowPreference = '1y';
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      this.seoService.setTitle($localize`Nodes per network`);
 | 
					      this.seoService.setTitle($localize`Lightning nodes per network`);
 | 
				
			||||||
      this.miningWindowPreference = this.miningService.getDefaultTimespan('all');
 | 
					      this.miningWindowPreference = this.miningService.getDefaultTimespan('all');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
 | 
					    this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,58 @@
 | 
				
			|||||||
 | 
					<div class="full-container h-100">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="card-header">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
 | 
				
			||||||
 | 
					      <span i18n="lightning.nodes-per-country">Lightning nodes per country</span>
 | 
				
			||||||
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="container pb-lg-0 bottom-padding">
 | 
				
			||||||
 | 
					    <div class="pb-lg-5">
 | 
				
			||||||
 | 
					      <div class="chart w-100" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
 | 
				
			||||||
 | 
					        (chartInit)="onChartInit($event)">
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="text-center loadingGraphs" *ngIf="isLoading">
 | 
				
			||||||
 | 
					      <div class="spinner-border text-light"></div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <table class="table table-borderless text-center m-auto" style="max-width: 900px">
 | 
				
			||||||
 | 
					      <thead>
 | 
				
			||||||
 | 
					        <tr>
 | 
				
			||||||
 | 
					          <th class="text-left rank" i18n="mining.rank">Rank</th>
 | 
				
			||||||
 | 
					          <th class="text-left name" i18n="lightning.as-name">Name</th>
 | 
				
			||||||
 | 
					          <th class="text-right share" i18n="lightning.share">Share</th>
 | 
				
			||||||
 | 
					          <th class="text-right nodes" i18n="lightning.nodes-count">Nodes</th>
 | 
				
			||||||
 | 
					          <th class="text-right capacity" i18n="lightning.capacity">Capacity</th>
 | 
				
			||||||
 | 
					        </tr>
 | 
				
			||||||
 | 
					      </thead>
 | 
				
			||||||
 | 
					      <tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerCountryObservable$ | async) as countries">
 | 
				
			||||||
 | 
					        <tr *ngFor="let country of countries">
 | 
				
			||||||
 | 
					          <td class="text-left rank">{{ country.rank }}</td>
 | 
				
			||||||
 | 
					          <td class="text-left text-truncate name">
 | 
				
			||||||
 | 
					            <div class="d-flex">
 | 
				
			||||||
 | 
					              <span style="font-size: 20px">{{ country.flag }}</span>
 | 
				
			||||||
 | 
					               
 | 
				
			||||||
 | 
					              <a class="mt-auto mb-auto" [routerLink]="['/lightning/nodes/country' | relativeUrl, country.iso]">{{ country.name.en }}</a>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="text-right share">{{ country.share }}%</td>
 | 
				
			||||||
 | 
					          <td class="text-right nodes">{{ country.count }}</td>
 | 
				
			||||||
 | 
					          <td class="text-right capacity">
 | 
				
			||||||
 | 
					            <app-amount *ngIf="country.capacity > 100000000; else smallchannel" [satoshis]="country.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
 | 
				
			||||||
 | 
					            <ng-template #smallchannel>
 | 
				
			||||||
 | 
					              {{ country.capacity | amountShortener: 1 }}
 | 
				
			||||||
 | 
					              <span class="sats" i18n="shared.sats">sats</span>
 | 
				
			||||||
 | 
					            </ng-template>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					        </tr>
 | 
				
			||||||
 | 
					      </tbody>
 | 
				
			||||||
 | 
					    </table>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -0,0 +1,81 @@
 | 
				
			|||||||
 | 
					.sats {
 | 
				
			||||||
 | 
					  color: #ffffff66;
 | 
				
			||||||
 | 
					  font-size: 12px;
 | 
				
			||||||
 | 
					  top: 0px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.card-header {
 | 
				
			||||||
 | 
					  border-bottom: 0;
 | 
				
			||||||
 | 
					  font-size: 18px;
 | 
				
			||||||
 | 
					  @media (min-width: 465px) {
 | 
				
			||||||
 | 
					    font-size: 20px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.full-container {
 | 
				
			||||||
 | 
					  padding: 0px 15px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: calc(100% - 140px);
 | 
				
			||||||
 | 
					  @media (max-width: 992px) {
 | 
				
			||||||
 | 
					    height: calc(100% - 190px);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  @media (max-width: 575px) {
 | 
				
			||||||
 | 
					    height: calc(100% - 230px);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.chart {
 | 
				
			||||||
 | 
					  max-height: 400px;
 | 
				
			||||||
 | 
					  @media (max-width: 767.98px) {
 | 
				
			||||||
 | 
					    max-height: 230px;
 | 
				
			||||||
 | 
					    margin-top: -35px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.bottom-padding {
 | 
				
			||||||
 | 
					  @media (max-width: 992px) {
 | 
				
			||||||
 | 
					    padding-bottom: 65px
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    padding-bottom: 65px
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.rank {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.name {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 80%;
 | 
				
			||||||
 | 
					    max-width: 150px;
 | 
				
			||||||
 | 
					    padding-left: 0;
 | 
				
			||||||
 | 
					    padding-right: 0;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.share {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nodes {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 10%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.capacity {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 10%;
 | 
				
			||||||
 | 
					    max-width: 100px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,235 @@
 | 
				
			|||||||
 | 
					import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core';
 | 
				
			||||||
 | 
					import { Router } from '@angular/router';
 | 
				
			||||||
 | 
					import { EChartsOption, PieSeriesOption } from 'echarts';
 | 
				
			||||||
 | 
					import { map, Observable, share, tap } from 'rxjs';
 | 
				
			||||||
 | 
					import { chartColors } from 'src/app/app.constants';
 | 
				
			||||||
 | 
					import { ApiService } from 'src/app/services/api.service';
 | 
				
			||||||
 | 
					import { SeoService } from 'src/app/services/seo.service';
 | 
				
			||||||
 | 
					import { 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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-nodes-per-country-chart',
 | 
				
			||||||
 | 
					  templateUrl: './nodes-per-country-chart.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./nodes-per-country-chart.component.scss'],
 | 
				
			||||||
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class NodesPerCountryChartComponent implements OnInit {
 | 
				
			||||||
 | 
					  miningWindowPreference: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  isLoading = true;
 | 
				
			||||||
 | 
					  chartOptions: EChartsOption = {};
 | 
				
			||||||
 | 
					  chartInitOptions = {
 | 
				
			||||||
 | 
					    renderer: 'svg',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  timespan = '';
 | 
				
			||||||
 | 
					  chartInstance: any = undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @HostBinding('attr.dir') dir = 'ltr';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  nodesPerCountryObservable$: Observable<any>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private apiService: ApiService,
 | 
				
			||||||
 | 
					    private seoService: SeoService,
 | 
				
			||||||
 | 
					    private amountShortenerPipe: AmountShortenerPipe,
 | 
				
			||||||
 | 
					    private zone: NgZone,
 | 
				
			||||||
 | 
					    private stateService: StateService,
 | 
				
			||||||
 | 
					    private router: Router,
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnInit(): void {
 | 
				
			||||||
 | 
					    this.seoService.setTitle($localize`Lightning nodes per country`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.nodesPerCountryObservable$ = this.apiService.getNodesPerCountry()
 | 
				
			||||||
 | 
					      .pipe(
 | 
				
			||||||
 | 
					        map(data => {
 | 
				
			||||||
 | 
					          for (let i = 0; i < data.length; ++i) {
 | 
				
			||||||
 | 
					            data[i].rank = i + 1;
 | 
				
			||||||
 | 
					            data[i].iso = data[i].iso.toLowerCase();
 | 
				
			||||||
 | 
					            data[i].flag = getFlagEmoji(data[i].iso);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          return data.slice(0, 100);
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					        tap(data => {
 | 
				
			||||||
 | 
					          this.isLoading = false;
 | 
				
			||||||
 | 
					          this.prepareChartOptions(data);
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					        share()
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  generateChartSerieData(country) {
 | 
				
			||||||
 | 
					    const shareThreshold = this.isMobile() ? 2 : 1;
 | 
				
			||||||
 | 
					    const data: object[] = [];
 | 
				
			||||||
 | 
					    let totalShareOther = 0;
 | 
				
			||||||
 | 
					    let totalNodeOther = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let edgeDistance: string | number = '10%';
 | 
				
			||||||
 | 
					    if (this.isMobile()) {
 | 
				
			||||||
 | 
					      edgeDistance = 0;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    country.forEach((country) => {
 | 
				
			||||||
 | 
					      if (country.share < shareThreshold) {
 | 
				
			||||||
 | 
					        totalShareOther += country.share;
 | 
				
			||||||
 | 
					        totalNodeOther += country.count;
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      data.push({
 | 
				
			||||||
 | 
					        value: country.share,
 | 
				
			||||||
 | 
					        name: country.name.en + (this.isMobile() ? `` : ` (${country.share}%)`),
 | 
				
			||||||
 | 
					        label: {
 | 
				
			||||||
 | 
					          overflow: 'truncate',
 | 
				
			||||||
 | 
					          color: '#b1b1b1',
 | 
				
			||||||
 | 
					          alignTo: 'edge',
 | 
				
			||||||
 | 
					          edgeDistance: edgeDistance,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        tooltip: {
 | 
				
			||||||
 | 
					          show: !this.isMobile(),
 | 
				
			||||||
 | 
					          backgroundColor: 'rgba(17, 19, 31, 1)',
 | 
				
			||||||
 | 
					          borderRadius: 4,
 | 
				
			||||||
 | 
					          shadowColor: 'rgba(0, 0, 0, 0.5)',
 | 
				
			||||||
 | 
					          textStyle: {
 | 
				
			||||||
 | 
					            color: '#b1b1b1',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          borderColor: '#000',
 | 
				
			||||||
 | 
					          formatter: () => {
 | 
				
			||||||
 | 
					            return `<b style="color: white">${country.name.en} (${country.share}%)</b><br>` +
 | 
				
			||||||
 | 
					              $localize`${country.count.toString()} nodes<br>` +
 | 
				
			||||||
 | 
					              $localize`${this.amountShortenerPipe.transform(country.capacity / 100000000, 2)} BTC capacity`
 | 
				
			||||||
 | 
					            ;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        data: country.iso,
 | 
				
			||||||
 | 
					      } as PieSeriesOption);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 'Other'
 | 
				
			||||||
 | 
					    data.push({
 | 
				
			||||||
 | 
					      itemStyle: {
 | 
				
			||||||
 | 
					        color: 'grey',
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      value: totalShareOther,
 | 
				
			||||||
 | 
					      name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`),
 | 
				
			||||||
 | 
					      label: {
 | 
				
			||||||
 | 
					        overflow: 'truncate',
 | 
				
			||||||
 | 
					        color: '#b1b1b1',
 | 
				
			||||||
 | 
					        alignTo: 'edge',
 | 
				
			||||||
 | 
					        edgeDistance: edgeDistance
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      tooltip: {
 | 
				
			||||||
 | 
					        backgroundColor: 'rgba(17, 19, 31, 1)',
 | 
				
			||||||
 | 
					        borderRadius: 4,
 | 
				
			||||||
 | 
					        shadowColor: 'rgba(0, 0, 0, 0.5)',
 | 
				
			||||||
 | 
					        textStyle: {
 | 
				
			||||||
 | 
					          color: '#b1b1b1',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        borderColor: '#000',
 | 
				
			||||||
 | 
					        formatter: () => {
 | 
				
			||||||
 | 
					          return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` +
 | 
				
			||||||
 | 
					            totalNodeOther.toString() + ` nodes`;
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      data: 9999 as any
 | 
				
			||||||
 | 
					    } as PieSeriesOption);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return data;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  prepareChartOptions(country) {
 | 
				
			||||||
 | 
					    let pieSize = ['20%', '80%']; // Desktop
 | 
				
			||||||
 | 
					    if (this.isMobile()) {
 | 
				
			||||||
 | 
					      pieSize = ['15%', '60%'];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartOptions = {
 | 
				
			||||||
 | 
					      animation: false,
 | 
				
			||||||
 | 
					      color: chartColors,
 | 
				
			||||||
 | 
					      tooltip: {
 | 
				
			||||||
 | 
					        trigger: 'item',
 | 
				
			||||||
 | 
					        textStyle: {
 | 
				
			||||||
 | 
					          align: 'left',
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      series: [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          zlevel: 0,
 | 
				
			||||||
 | 
					          minShowLabelAngle: 3.6,
 | 
				
			||||||
 | 
					          name: 'Mining pool',
 | 
				
			||||||
 | 
					          type: 'pie',
 | 
				
			||||||
 | 
					          radius: pieSize,
 | 
				
			||||||
 | 
					          data: this.generateChartSerieData(country),
 | 
				
			||||||
 | 
					          labelLine: {
 | 
				
			||||||
 | 
					            lineStyle: {
 | 
				
			||||||
 | 
					              width: 2,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            length: this.isMobile() ? 1 : 20,
 | 
				
			||||||
 | 
					            length2: this.isMobile() ? 1 : undefined,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          label: {
 | 
				
			||||||
 | 
					            fontSize: 14,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          itemStyle: {
 | 
				
			||||||
 | 
					            borderRadius: 1,
 | 
				
			||||||
 | 
					            borderWidth: 1,
 | 
				
			||||||
 | 
					            borderColor: '#000',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          emphasis: {
 | 
				
			||||||
 | 
					            itemStyle: {
 | 
				
			||||||
 | 
					              shadowBlur: 40,
 | 
				
			||||||
 | 
					              shadowColor: 'rgba(0, 0, 0, 0.75)',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            labelLine: {
 | 
				
			||||||
 | 
					              lineStyle: {
 | 
				
			||||||
 | 
					                width: 4,
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  isMobile() {
 | 
				
			||||||
 | 
					    return (window.innerWidth <= 767.98);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onChartInit(ec) {
 | 
				
			||||||
 | 
					    if (this.chartInstance !== undefined) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    this.chartInstance = ec;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartInstance.on('click', (e) => {
 | 
				
			||||||
 | 
					      if (e.data.data === 9999) { // "Other"
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.zone.run(() => {
 | 
				
			||||||
 | 
					        const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/country/${e.data.data}`);
 | 
				
			||||||
 | 
					        this.router.navigate([url]);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onSaveChart() {
 | 
				
			||||||
 | 
					    const now = new Date();
 | 
				
			||||||
 | 
					    this.chartOptions.backgroundColor = '#11131f';
 | 
				
			||||||
 | 
					    this.chartInstance.setOption(this.chartOptions);
 | 
				
			||||||
 | 
					    download(this.chartInstance.getDataURL({
 | 
				
			||||||
 | 
					      pixelRatio: 2,
 | 
				
			||||||
 | 
					      excludeComponents: ['dataZoom'],
 | 
				
			||||||
 | 
					    }), `lightning-nodes-per-country-${Math.round(now.getTime() / 1000)}.svg`);
 | 
				
			||||||
 | 
					    this.chartOptions.backgroundColor = 'none';
 | 
				
			||||||
 | 
					    this.chartInstance.setOption(this.chartOptions);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  isEllipsisActive(e) {
 | 
				
			||||||
 | 
					    return (e.offsetWidth < e.scrollWidth);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -0,0 +1,45 @@
 | 
				
			|||||||
 | 
					<div class="container-xl full-height" style="min-height: 335px">
 | 
				
			||||||
 | 
					  <h1 class="float-left" i18n="lightning.nodes-in-country">
 | 
				
			||||||
 | 
					    <span>Lightning nodes in {{ country?.name }}</span>
 | 
				
			||||||
 | 
					    <span style="font-size: 50px; vertical-align:sub;"> {{ country?.flag }}</span>
 | 
				
			||||||
 | 
					  </h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div style="min-height: 295px">
 | 
				
			||||||
 | 
					    <table class="table table-borderless">
 | 
				
			||||||
 | 
					      <thead>
 | 
				
			||||||
 | 
					        <th class="alias text-left" i18n="lightning.alias">Alias</th>
 | 
				
			||||||
 | 
					        <th class="timestamp-first text-left" i18n="lightning.first_seen">First seen</th>
 | 
				
			||||||
 | 
					        <th class="timestamp-update text-left" i18n="lightning.last_update">Last update</th>
 | 
				
			||||||
 | 
					        <th class="capacity text-right" i18n="lightning.capacity">Capacity</th>
 | 
				
			||||||
 | 
					        <th class="channels text-right" i18n="lightning.channels">Channels</th>
 | 
				
			||||||
 | 
					        <th class="city text-right" i18n="lightning.city">City</th>
 | 
				
			||||||
 | 
					      </thead>
 | 
				
			||||||
 | 
					      <tbody *ngIf="nodes$ | async as nodes">
 | 
				
			||||||
 | 
					        <tr *ngFor="let node of nodes; let i= index; trackBy: trackByPublicKey">
 | 
				
			||||||
 | 
					          <td class="alias text-left text-truncate">
 | 
				
			||||||
 | 
					            <a [routerLink]="['/lightning/node/' | relativeUrl, node.public_key]">{{ node.alias }}</a>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="timestamp-first text-left">
 | 
				
			||||||
 | 
					            <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.first_seen"></app-timestamp>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="timestamp-update text-left">
 | 
				
			||||||
 | 
					            <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.updated_at"></app-timestamp>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="capacity text-right">
 | 
				
			||||||
 | 
					            <app-amount *ngIf="node.capacity > 100000000; else smallchannel" [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
 | 
				
			||||||
 | 
					            <ng-template #smallchannel>
 | 
				
			||||||
 | 
					              {{ node.capacity | amountShortener: 1 }}
 | 
				
			||||||
 | 
					              <span class="sats" i18n="shared.sats">sats</span>
 | 
				
			||||||
 | 
					            </ng-template>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="channels text-right">
 | 
				
			||||||
 | 
					            {{ node.channels }}
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="city text-right text-truncate">
 | 
				
			||||||
 | 
					            {{ node?.city?.en ?? '-' }}
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					      </tbody>
 | 
				
			||||||
 | 
					    </table>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -0,0 +1,56 @@
 | 
				
			|||||||
 | 
					.container-xl {
 | 
				
			||||||
 | 
					  max-width: 1400px;
 | 
				
			||||||
 | 
					  padding-bottom: 100px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.sats {
 | 
				
			||||||
 | 
					  color: #ffffff66;
 | 
				
			||||||
 | 
					  font-size: 12px;
 | 
				
			||||||
 | 
					  top: 0px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.alias {
 | 
				
			||||||
 | 
					  width: 30%;
 | 
				
			||||||
 | 
					  max-width: 400px;
 | 
				
			||||||
 | 
					  padding-right: 70px;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 50%;
 | 
				
			||||||
 | 
					    max-width: 150px;
 | 
				
			||||||
 | 
					    padding-right: 0px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.timestamp-first {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.timestamp-update {
 | 
				
			||||||
 | 
					  width: 16%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.capacity {
 | 
				
			||||||
 | 
					  width: 10%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 25%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.channels {
 | 
				
			||||||
 | 
					  width: 10%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 25%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.city {
 | 
				
			||||||
 | 
					  max-width: 150px;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,41 @@
 | 
				
			|||||||
 | 
					import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 | 
				
			||||||
 | 
					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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-nodes-per-country',
 | 
				
			||||||
 | 
					  templateUrl: './nodes-per-country.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./nodes-per-country.component.scss'],
 | 
				
			||||||
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class NodesPerCountry implements OnInit {
 | 
				
			||||||
 | 
					  nodes$: Observable<any>;
 | 
				
			||||||
 | 
					  country: {name: string, flag: string};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private apiService: ApiService,
 | 
				
			||||||
 | 
					    private seoService: SeoService,
 | 
				
			||||||
 | 
					    private route: ActivatedRoute,
 | 
				
			||||||
 | 
					  ) { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnInit(): void {
 | 
				
			||||||
 | 
					    this.nodes$ = this.apiService.getNodeForCountry$(this.route.snapshot.params.country)
 | 
				
			||||||
 | 
					      .pipe(
 | 
				
			||||||
 | 
					        map(response => {
 | 
				
			||||||
 | 
					          this.country = {
 | 
				
			||||||
 | 
					            name: response.country.en,
 | 
				
			||||||
 | 
					            flag: getFlagEmoji(this.route.snapshot.params.country)
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          this.seoService.setTitle($localize`Lightning nodes in ${this.country.name}`);
 | 
				
			||||||
 | 
					          return response.nodes;
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  trackByPublicKey(index: number, node: any) {
 | 
				
			||||||
 | 
					    return node.public_key;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,48 @@
 | 
				
			|||||||
 | 
					<div class="full-container h-100">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="card-header">
 | 
				
			||||||
 | 
					    <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
 | 
				
			||||||
 | 
					      <span i18n="lightning.nodes-per-isp">Lightning nodes per ISP</span>
 | 
				
			||||||
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="container pb-lg-0 bottom-padding">
 | 
				
			||||||
 | 
					    <div class="pb-lg-5" *ngIf="nodesPerAsObservable$ | async">
 | 
				
			||||||
 | 
					      <div class="chart w-100" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
 | 
				
			||||||
 | 
					        (chartInit)="onChartInit($event)">
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="text-center loadingGraphs" *ngIf="isLoading">
 | 
				
			||||||
 | 
					      <div class="spinner-border text-light"></div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <table class="table table-borderless text-center m-auto" style="max-width: 900px">
 | 
				
			||||||
 | 
					      <thead>
 | 
				
			||||||
 | 
					        <tr>
 | 
				
			||||||
 | 
					          <th class="rank text-left pl-0" i18n="mining.rank">Rank</th>
 | 
				
			||||||
 | 
					          <th class="name text-left" i18n="lightning.isp">ISP</th>
 | 
				
			||||||
 | 
					          <th class="share text-right" i18n="lightning.share">Share</th>
 | 
				
			||||||
 | 
					          <th class="nodes text-right" i18n="lightning.nodes-count">Nodes</th>
 | 
				
			||||||
 | 
					          <th class="capacity text-right pr-0" i18n="lightning.capacity">Capacity</th>
 | 
				
			||||||
 | 
					        </tr>
 | 
				
			||||||
 | 
					      </thead>
 | 
				
			||||||
 | 
					      <tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerAsObservable$ | async) as asList">
 | 
				
			||||||
 | 
					        <tr *ngFor="let asEntry of asList">
 | 
				
			||||||
 | 
					          <td class="rank text-left pl-0">{{ asEntry.rank }}</td>
 | 
				
			||||||
 | 
					          <td class="name text-left text-truncate"  style="max-width: 100px">
 | 
				
			||||||
 | 
					          <a [routerLink]="[('/lightning/nodes/isp/' + asEntry.ispId) | relativeUrl]">{{ asEntry.name }}</a>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="share text-right">{{ asEntry.share }}%</td>
 | 
				
			||||||
 | 
					          <td class="nodes text-right">{{ asEntry.count }}</td>
 | 
				
			||||||
 | 
					          <td class="capacity text-right pr-0"><app-amount [satoshis]="asEntry.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount></td>
 | 
				
			||||||
 | 
					        </tr>
 | 
				
			||||||
 | 
					      </tbody>
 | 
				
			||||||
 | 
					    </table>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -0,0 +1,75 @@
 | 
				
			|||||||
 | 
					.card-header {
 | 
				
			||||||
 | 
					  border-bottom: 0;
 | 
				
			||||||
 | 
					  font-size: 18px;
 | 
				
			||||||
 | 
					  @media (min-width: 465px) {
 | 
				
			||||||
 | 
					    font-size: 20px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.full-container {
 | 
				
			||||||
 | 
					  padding: 0px 15px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: calc(100% - 140px);
 | 
				
			||||||
 | 
					  @media (max-width: 992px) {
 | 
				
			||||||
 | 
					    height: calc(100% - 190px);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  @media (max-width: 575px) {
 | 
				
			||||||
 | 
					    height: calc(100% - 230px);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.chart {
 | 
				
			||||||
 | 
					  max-height: 400px;
 | 
				
			||||||
 | 
					  @media (max-width: 767.98px) {
 | 
				
			||||||
 | 
					    max-height: 230px;
 | 
				
			||||||
 | 
					    margin-top: -35px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.bottom-padding {
 | 
				
			||||||
 | 
					  @media (max-width: 992px) {
 | 
				
			||||||
 | 
					    padding-bottom: 65px
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    padding-bottom: 65px
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.rank {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.name {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 80%;
 | 
				
			||||||
 | 
					    max-width: 150px;
 | 
				
			||||||
 | 
					    padding-left: 0;
 | 
				
			||||||
 | 
					    padding-right: 0;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.share {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nodes {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 10%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.capacity {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 10%;
 | 
				
			||||||
 | 
					    max-width: 100px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,231 @@
 | 
				
			|||||||
 | 
					import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core';
 | 
				
			||||||
 | 
					import { Router } from '@angular/router';
 | 
				
			||||||
 | 
					import { EChartsOption, PieSeriesOption } from 'echarts';
 | 
				
			||||||
 | 
					import { map, Observable, share, tap } from 'rxjs';
 | 
				
			||||||
 | 
					import { chartColors } from 'src/app/app.constants';
 | 
				
			||||||
 | 
					import { ApiService } from 'src/app/services/api.service';
 | 
				
			||||||
 | 
					import { SeoService } from 'src/app/services/seo.service';
 | 
				
			||||||
 | 
					import { 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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-nodes-per-isp-chart',
 | 
				
			||||||
 | 
					  templateUrl: './nodes-per-isp-chart.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./nodes-per-isp-chart.component.scss'],
 | 
				
			||||||
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class NodesPerISPChartComponent implements OnInit {
 | 
				
			||||||
 | 
					  miningWindowPreference: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  isLoading = true;
 | 
				
			||||||
 | 
					  chartOptions: EChartsOption = {};
 | 
				
			||||||
 | 
					  chartInitOptions = {
 | 
				
			||||||
 | 
					    renderer: 'svg',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  timespan = '';
 | 
				
			||||||
 | 
					  chartInstance: any = undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @HostBinding('attr.dir') dir = 'ltr';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  nodesPerAsObservable$: Observable<any>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private apiService: ApiService,
 | 
				
			||||||
 | 
					    private seoService: SeoService,
 | 
				
			||||||
 | 
					    private amountShortenerPipe: AmountShortenerPipe,
 | 
				
			||||||
 | 
					    private router: Router,
 | 
				
			||||||
 | 
					    private zone: NgZone,
 | 
				
			||||||
 | 
					    private stateService: StateService,
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnInit(): void {
 | 
				
			||||||
 | 
					    this.seoService.setTitle($localize`Lightning nodes per ISP`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.nodesPerAsObservable$ = this.apiService.getNodesPerAs()
 | 
				
			||||||
 | 
					      .pipe(
 | 
				
			||||||
 | 
					        tap(data => {
 | 
				
			||||||
 | 
					          this.isLoading = false;
 | 
				
			||||||
 | 
					          this.prepareChartOptions(data);
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					        map(data => {
 | 
				
			||||||
 | 
					          for (let i = 0; i < data.length; ++i) {
 | 
				
			||||||
 | 
					            data[i].rank = i + 1;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          return data.slice(0, 100);
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					        share()
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  generateChartSerieData(as) {
 | 
				
			||||||
 | 
					    const shareThreshold = this.isMobile() ? 2 : 1;
 | 
				
			||||||
 | 
					    const data: object[] = [];
 | 
				
			||||||
 | 
					    let totalShareOther = 0;
 | 
				
			||||||
 | 
					    let totalNodeOther = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let edgeDistance: string | number = '10%';
 | 
				
			||||||
 | 
					    if (this.isMobile()) {
 | 
				
			||||||
 | 
					      edgeDistance = 0;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    as.forEach((as) => {
 | 
				
			||||||
 | 
					      if (as.share < shareThreshold) {
 | 
				
			||||||
 | 
					        totalShareOther += as.share;
 | 
				
			||||||
 | 
					        totalNodeOther += as.count;
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      data.push({
 | 
				
			||||||
 | 
					        value: as.share,
 | 
				
			||||||
 | 
					        name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`),
 | 
				
			||||||
 | 
					        label: {
 | 
				
			||||||
 | 
					          overflow: 'truncate',
 | 
				
			||||||
 | 
					          color: '#b1b1b1',
 | 
				
			||||||
 | 
					          alignTo: 'edge',
 | 
				
			||||||
 | 
					          edgeDistance: edgeDistance,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        tooltip: {
 | 
				
			||||||
 | 
					          show: !this.isMobile(),
 | 
				
			||||||
 | 
					          backgroundColor: 'rgba(17, 19, 31, 1)',
 | 
				
			||||||
 | 
					          borderRadius: 4,
 | 
				
			||||||
 | 
					          shadowColor: 'rgba(0, 0, 0, 0.5)',
 | 
				
			||||||
 | 
					          textStyle: {
 | 
				
			||||||
 | 
					            color: '#b1b1b1',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          borderColor: '#000',
 | 
				
			||||||
 | 
					          formatter: () => {
 | 
				
			||||||
 | 
					            return `<b style="color: white">${as.name} (${as.share}%)</b><br>` +
 | 
				
			||||||
 | 
					              $localize`${as.count.toString()} nodes<br>` +
 | 
				
			||||||
 | 
					              $localize`${this.amountShortenerPipe.transform(as.capacity / 100000000, 2)} BTC capacity`
 | 
				
			||||||
 | 
					            ;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        data: as.ispId,
 | 
				
			||||||
 | 
					      } as PieSeriesOption);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 'Other'
 | 
				
			||||||
 | 
					    data.push({
 | 
				
			||||||
 | 
					      itemStyle: {
 | 
				
			||||||
 | 
					        color: 'grey',
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      value: totalShareOther,
 | 
				
			||||||
 | 
					      name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`),
 | 
				
			||||||
 | 
					      label: {
 | 
				
			||||||
 | 
					        overflow: 'truncate',
 | 
				
			||||||
 | 
					        color: '#b1b1b1',
 | 
				
			||||||
 | 
					        alignTo: 'edge',
 | 
				
			||||||
 | 
					        edgeDistance: edgeDistance
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      tooltip: {
 | 
				
			||||||
 | 
					        backgroundColor: 'rgba(17, 19, 31, 1)',
 | 
				
			||||||
 | 
					        borderRadius: 4,
 | 
				
			||||||
 | 
					        shadowColor: 'rgba(0, 0, 0, 0.5)',
 | 
				
			||||||
 | 
					        textStyle: {
 | 
				
			||||||
 | 
					          color: '#b1b1b1',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        borderColor: '#000',
 | 
				
			||||||
 | 
					        formatter: () => {
 | 
				
			||||||
 | 
					          return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` +
 | 
				
			||||||
 | 
					            totalNodeOther.toString() + ` nodes`;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      data: 9999 as any,
 | 
				
			||||||
 | 
					    } as PieSeriesOption);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return data;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  prepareChartOptions(as) {
 | 
				
			||||||
 | 
					    let pieSize = ['20%', '80%']; // Desktop
 | 
				
			||||||
 | 
					    if (this.isMobile()) {
 | 
				
			||||||
 | 
					      pieSize = ['15%', '60%'];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartOptions = {
 | 
				
			||||||
 | 
					      color: chartColors,
 | 
				
			||||||
 | 
					      tooltip: {
 | 
				
			||||||
 | 
					        trigger: 'item',
 | 
				
			||||||
 | 
					        textStyle: {
 | 
				
			||||||
 | 
					          align: 'left',
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      series: [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          zlevel: 0,
 | 
				
			||||||
 | 
					          minShowLabelAngle: 3.6,
 | 
				
			||||||
 | 
					          name: 'Lightning nodes',
 | 
				
			||||||
 | 
					          type: 'pie',
 | 
				
			||||||
 | 
					          radius: pieSize,
 | 
				
			||||||
 | 
					          data: this.generateChartSerieData(as),
 | 
				
			||||||
 | 
					          labelLine: {
 | 
				
			||||||
 | 
					            lineStyle: {
 | 
				
			||||||
 | 
					              width: 2,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            length: this.isMobile() ? 1 : 20,
 | 
				
			||||||
 | 
					            length2: this.isMobile() ? 1 : undefined,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          label: {
 | 
				
			||||||
 | 
					            fontSize: 14,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          itemStyle: {
 | 
				
			||||||
 | 
					            borderRadius: 1,
 | 
				
			||||||
 | 
					            borderWidth: 1,
 | 
				
			||||||
 | 
					            borderColor: '#000',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          emphasis: {
 | 
				
			||||||
 | 
					            itemStyle: {
 | 
				
			||||||
 | 
					              shadowBlur: 40,
 | 
				
			||||||
 | 
					              shadowColor: 'rgba(0, 0, 0, 0.75)',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            labelLine: {
 | 
				
			||||||
 | 
					              lineStyle: {
 | 
				
			||||||
 | 
					                width: 4,
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  isMobile() {
 | 
				
			||||||
 | 
					    return (window.innerWidth <= 767.98);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onChartInit(ec) {
 | 
				
			||||||
 | 
					    if (this.chartInstance !== undefined) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    this.chartInstance = ec;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.chartInstance.on('click', (e) => {
 | 
				
			||||||
 | 
					      if (e.data.data === 9999) { // "Other"
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.zone.run(() => {
 | 
				
			||||||
 | 
					        const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/isp/${e.data.data}`);
 | 
				
			||||||
 | 
					        this.router.navigate([url]);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onSaveChart() {
 | 
				
			||||||
 | 
					    const now = new Date();
 | 
				
			||||||
 | 
					    this.chartOptions.backgroundColor = '#11131f';
 | 
				
			||||||
 | 
					    this.chartInstance.setOption(this.chartOptions);
 | 
				
			||||||
 | 
					    download(this.chartInstance.getDataURL({
 | 
				
			||||||
 | 
					      pixelRatio: 2,
 | 
				
			||||||
 | 
					      excludeComponents: ['dataZoom'],
 | 
				
			||||||
 | 
					    }), `ln-nodes-per-as-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`);
 | 
				
			||||||
 | 
					    this.chartOptions.backgroundColor = 'none';
 | 
				
			||||||
 | 
					    this.chartInstance.setOption(this.chartOptions);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  isEllipsisActive(e) {
 | 
				
			||||||
 | 
					    return (e.offsetWidth < e.scrollWidth);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					<div class="container-xl full-height" style="min-height: 335px">
 | 
				
			||||||
 | 
					  <h1 class="float-left" i18n="lightning.nodes-for-isp">Lightning nodes on ISP: {{ isp?.name }} [AS {{isp?.id}}]</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div style="min-height: 295px">
 | 
				
			||||||
 | 
					    <table class="table table-borderless">
 | 
				
			||||||
 | 
					      <thead>
 | 
				
			||||||
 | 
					        <th class="alias text-left" i18n="lightning.alias">Alias</th>
 | 
				
			||||||
 | 
					        <th class="timestamp-first text-left" i18n="lightning.first_seen">First seen</th>
 | 
				
			||||||
 | 
					        <th class="timestamp-update text-left" i18n="lightning.last_update">Last update</th>
 | 
				
			||||||
 | 
					        <th class="capacity text-right" i18n="lightning.capacity">Capacity</th>
 | 
				
			||||||
 | 
					        <th class="channels text-right" i18n="lightning.channels">Channels</th>
 | 
				
			||||||
 | 
					        <th class="city text-right" i18n="lightning.city">City</th>
 | 
				
			||||||
 | 
					      </thead>
 | 
				
			||||||
 | 
					      <tbody *ngIf="nodes$ | async as nodes">
 | 
				
			||||||
 | 
					        <tr *ngFor="let node of nodes; let i= index; trackBy: trackByPublicKey">
 | 
				
			||||||
 | 
					          <td class="alias text-left text-truncate">
 | 
				
			||||||
 | 
					            <a [routerLink]="['/lightning/node/' | relativeUrl, node.public_key]">{{ node.alias }}</a>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="timestamp-first text-left">
 | 
				
			||||||
 | 
					            <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.first_seen"></app-timestamp>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="timestamp-update text-left">
 | 
				
			||||||
 | 
					            <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.updated_at"></app-timestamp>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="capacity text-right">
 | 
				
			||||||
 | 
					            <app-amount *ngIf="node.capacity > 100000000; else smallchannel" [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
 | 
				
			||||||
 | 
					            <ng-template #smallchannel>
 | 
				
			||||||
 | 
					              {{ node.capacity | amountShortener: 1 }}
 | 
				
			||||||
 | 
					              <span class="sats" i18n="shared.sats">sats</span>
 | 
				
			||||||
 | 
					            </ng-template>
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="channels text-right">
 | 
				
			||||||
 | 
					            {{ node.channels }}
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					          <td class="city text-right text-truncate">
 | 
				
			||||||
 | 
					            {{ node?.city?.en ?? '-' }}
 | 
				
			||||||
 | 
					          </td>
 | 
				
			||||||
 | 
					      </tbody>
 | 
				
			||||||
 | 
					    </table>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -0,0 +1,62 @@
 | 
				
			|||||||
 | 
					.container-xl {
 | 
				
			||||||
 | 
					  max-width: 1400px;
 | 
				
			||||||
 | 
					  padding-bottom: 100px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.sats {
 | 
				
			||||||
 | 
					  color: #ffffff66;
 | 
				
			||||||
 | 
					  font-size: 12px;
 | 
				
			||||||
 | 
					  top: 0px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.alias {
 | 
				
			||||||
 | 
					  width: 30%;
 | 
				
			||||||
 | 
					  max-width: 400px;
 | 
				
			||||||
 | 
					  padding-right: 70px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 50%;
 | 
				
			||||||
 | 
					    max-width: 150px;
 | 
				
			||||||
 | 
					    padding-right: 0px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.timestamp-first {
 | 
				
			||||||
 | 
					  width: 20%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.timestamp-update {
 | 
				
			||||||
 | 
					  width: 16%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.capacity {
 | 
				
			||||||
 | 
					  width: 10%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 25%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.channels {
 | 
				
			||||||
 | 
					  width: 10%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    width: 25%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.city {
 | 
				
			||||||
 | 
					  max-width: 150px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @media (max-width: 576px) {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 | 
				
			||||||
 | 
					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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-nodes-per-isp',
 | 
				
			||||||
 | 
					  templateUrl: './nodes-per-isp.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./nodes-per-isp.component.scss'],
 | 
				
			||||||
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class NodesPerISP implements OnInit {
 | 
				
			||||||
 | 
					  nodes$: Observable<any>;
 | 
				
			||||||
 | 
					  isp: {name: string, id: number};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private apiService: ApiService,
 | 
				
			||||||
 | 
					    private seoService: SeoService,
 | 
				
			||||||
 | 
					    private route: ActivatedRoute,
 | 
				
			||||||
 | 
					  ) { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnInit(): void {
 | 
				
			||||||
 | 
					    this.nodes$ = this.apiService.getNodeForISP$(this.route.snapshot.params.isp)
 | 
				
			||||||
 | 
					      .pipe(
 | 
				
			||||||
 | 
					        map(response => {
 | 
				
			||||||
 | 
					          this.isp = {
 | 
				
			||||||
 | 
					            name: response.isp,
 | 
				
			||||||
 | 
					            id: this.route.snapshot.params.isp
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          this.seoService.setTitle($localize`Lightning nodes on ISP: ${response.isp} [AS${this.route.snapshot.params.isp}]`);
 | 
				
			||||||
 | 
					          return response.nodes;
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  trackByPublicKey(index: number, node: any) {
 | 
				
			||||||
 | 
					    return node.public_key;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,10 +1,12 @@
 | 
				
			|||||||
<div [class]="widget === false ? 'full-container' : ''">
 | 
					<div [class]="widget === false ? 'full-container' : ''">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
 | 
					  <div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
 | 
				
			||||||
    <span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span>
 | 
					    <div class="d-flex d-md-block align-items-baseline">
 | 
				
			||||||
    <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
					      <span i18n="mining.channels-and-capacity">Channels & Capacity</span>
 | 
				
			||||||
 | 
					      <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
 | 
				
			||||||
        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
					        <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(capacityObservable$ | async) as stats">
 | 
					    <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(capacityObservable$ | async) as stats">
 | 
				
			||||||
      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
					      <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
 | 
				
			||||||
 | 
				
			|||||||
@ -228,10 +228,20 @@ export class ApiService {
 | 
				
			|||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getBlockAudit$(hash: string) : Observable<any> {
 | 
				
			||||||
 | 
					    return this.httpClient.get<any>(
 | 
				
			||||||
 | 
					      this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/` + hash, { observe: 'response' }
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getRewardStats$(blockCount: number = 144): Observable<RewardStats> {
 | 
					  getRewardStats$(blockCount: number = 144): Observable<RewardStats> {
 | 
				
			||||||
    return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`);
 | 
					    return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getEnterpriseInfo$(name: string): Observable<any> {
 | 
				
			||||||
 | 
					    return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + `/api/v1/enterprise/info/` + name);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getChannelByTxIds$(txIds: string[]): Observable<{ inputs: any[], outputs: any[] }> {
 | 
					  getChannelByTxIds$(txIds: string[]): Observable<{ inputs: any[], outputs: any[] }> {
 | 
				
			||||||
    let params = new HttpParams();
 | 
					    let params = new HttpParams();
 | 
				
			||||||
    txIds.forEach((txId: string) => {
 | 
					    txIds.forEach((txId: string) => {
 | 
				
			||||||
@ -245,4 +255,23 @@ export class ApiService {
 | 
				
			|||||||
    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
 | 
					    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getNodesPerAs(): Observable<any> {
 | 
				
			||||||
 | 
					    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getNodeForCountry$(country: string): Observable<any> {
 | 
				
			||||||
 | 
					    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/country/' + country);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getNodeForISP$(isp: string): Observable<any> {
 | 
				
			||||||
 | 
					    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp/' + isp);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getNodesPerCountry(): Observable<any> {
 | 
				
			||||||
 | 
					    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getChannelsGeo$(): Observable<any> {
 | 
				
			||||||
 | 
					    return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels-geo');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -14,6 +14,7 @@ export class AssetsService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  getAssetsJson$: Observable<{ array: AssetExtended[]; objects: any}>;
 | 
					  getAssetsJson$: Observable<{ array: AssetExtended[]; objects: any}>;
 | 
				
			||||||
  getAssetsMinimalJson$: Observable<any>;
 | 
					  getAssetsMinimalJson$: Observable<any>;
 | 
				
			||||||
 | 
					  getWorldMapJson$: Observable<any>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    private httpClient: HttpClient,
 | 
					    private httpClient: HttpClient,
 | 
				
			||||||
@ -65,5 +66,7 @@ export class AssetsService {
 | 
				
			|||||||
      }),
 | 
					      }),
 | 
				
			||||||
      shareReplay(1),
 | 
					      shareReplay(1),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.getWorldMapJson$ = this.httpClient.get(apiBaseUrl + '/resources/worldmap.json').pipe(shareReplay());
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										79
									
								
								frontend/src/app/services/enterprise.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								frontend/src/app/services/enterprise.service.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,79 @@
 | 
				
			|||||||
 | 
					import { DOCUMENT } from '@angular/common';
 | 
				
			||||||
 | 
					import { Inject, Injectable } from '@angular/core';
 | 
				
			||||||
 | 
					import { ApiService } from './api.service';
 | 
				
			||||||
 | 
					import { SeoService } from './seo.service';
 | 
				
			||||||
 | 
					import { StateService } from './state.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Injectable({
 | 
				
			||||||
 | 
					  providedIn: 'root'
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class EnterpriseService {
 | 
				
			||||||
 | 
					  exclusiveHostName = '.mempool.space';
 | 
				
			||||||
 | 
					  subdomain: string | null = null;
 | 
				
			||||||
 | 
					  info: object = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    @Inject(DOCUMENT) private document: Document,
 | 
				
			||||||
 | 
					    private apiService: ApiService,
 | 
				
			||||||
 | 
					    private seoService: SeoService,
 | 
				
			||||||
 | 
					    private stateService: StateService,
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    const subdomain = this.document.location.hostname.indexOf(this.exclusiveHostName) > -1
 | 
				
			||||||
 | 
					      && this.document.location.hostname.split(this.exclusiveHostName)[0] || false;
 | 
				
			||||||
 | 
					    if (subdomain && subdomain.match(/^[A-z0-9-_]+$/)) {
 | 
				
			||||||
 | 
					      this.subdomain = subdomain;
 | 
				
			||||||
 | 
					      this.fetchSubdomainInfo();
 | 
				
			||||||
 | 
					      this.disableSubnetworks();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      this.insertMatomo();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getSubdomain() {
 | 
				
			||||||
 | 
					    return this.subdomain;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  disableSubnetworks() {
 | 
				
			||||||
 | 
					    this.stateService.env.TESTNET_ENABLED = false;
 | 
				
			||||||
 | 
					    this.stateService.env.LIQUID_ENABLED = false;
 | 
				
			||||||
 | 
					    this.stateService.env.LIQUID_TESTNET_ENABLED = false;
 | 
				
			||||||
 | 
					    this.stateService.env.SIGNET_ENABLED = false;
 | 
				
			||||||
 | 
					    this.stateService.env.BISQ_ENABLED = false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  fetchSubdomainInfo() {
 | 
				
			||||||
 | 
					    this.apiService.getEnterpriseInfo$(this.subdomain).subscribe((info) => {
 | 
				
			||||||
 | 
					      this.info = info;
 | 
				
			||||||
 | 
					      this.insertMatomo(info.site_id);
 | 
				
			||||||
 | 
					      this.seoService.setEnterpriseTitle(info.title);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    (error) => {
 | 
				
			||||||
 | 
					      if (error.status === 404) {
 | 
				
			||||||
 | 
					        window.location.href = 'https://mempool.space';
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  insertMatomo(siteId = 5) {
 | 
				
			||||||
 | 
					    let statsUrl = '//stats.mempool.space/';
 | 
				
			||||||
 | 
					    if (this.document.location.hostname === 'liquid.network') {
 | 
				
			||||||
 | 
					      statsUrl = '//stats.liquid.network/';
 | 
				
			||||||
 | 
					      siteId = 8;
 | 
				
			||||||
 | 
					    } else if (this.document.location.hostname === 'bisq.markets') {
 | 
				
			||||||
 | 
					      statsUrl = '//stats.bisq.markets/';
 | 
				
			||||||
 | 
					      siteId = 7;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // @ts-ignore
 | 
				
			||||||
 | 
					    const _paq = window._paq = window._paq || [];
 | 
				
			||||||
 | 
					    _paq.push(['disableCookies']);
 | 
				
			||||||
 | 
					    _paq.push(['trackPageView']);
 | 
				
			||||||
 | 
					    _paq.push(['enableLinkTracking']);
 | 
				
			||||||
 | 
					    (function() {
 | 
				
			||||||
 | 
					      _paq.push(['setTrackerUrl', statsUrl+'m.php']);
 | 
				
			||||||
 | 
					      _paq.push(['setSiteId', siteId.toString()]);
 | 
				
			||||||
 | 
					      const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
 | 
				
			||||||
 | 
					      g.type='text/javascript'; g.async=true; g.src=statsUrl+'m.js'; s.parentNode.insertBefore(g,s);
 | 
				
			||||||
 | 
					    })();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -7,6 +7,7 @@ import { StateService } from './state.service';
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
export class SeoService {
 | 
					export class SeoService {
 | 
				
			||||||
  network = '';
 | 
					  network = '';
 | 
				
			||||||
 | 
					  baseTitle = 'mempool';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    private titleService: Title,
 | 
					    private titleService: Title,
 | 
				
			||||||
@ -26,18 +27,23 @@ export class SeoService {
 | 
				
			|||||||
    this.metaService.updateTag({ property: 'og:title', content: this.getTitle()});
 | 
					    this.metaService.updateTag({ property: 'og:title', content: this.getTitle()});
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setEnterpriseTitle(title: string) {
 | 
				
			||||||
 | 
					    this.baseTitle = title + ' - ' + this.baseTitle;
 | 
				
			||||||
 | 
					    this.resetTitle();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getTitle(): string {
 | 
					  getTitle(): string {
 | 
				
			||||||
    if (this.network === 'testnet')
 | 
					    if (this.network === 'testnet')
 | 
				
			||||||
      return 'mempool - Bitcoin Testnet';
 | 
					      return this.baseTitle + ' - Bitcoin Testnet';
 | 
				
			||||||
    if (this.network === 'signet')
 | 
					    if (this.network === 'signet')
 | 
				
			||||||
      return 'mempool - Bitcoin Signet';
 | 
					      return this.baseTitle + ' - Bitcoin Signet';
 | 
				
			||||||
    if (this.network === 'liquid')
 | 
					    if (this.network === 'liquid')
 | 
				
			||||||
      return 'mempool - Liquid Network';
 | 
					      return this.baseTitle + ' - Liquid Network';
 | 
				
			||||||
    if (this.network === 'liquidtestnet')
 | 
					    if (this.network === 'liquidtestnet')
 | 
				
			||||||
      return 'mempool - Liquid Testnet';
 | 
					      return this.baseTitle + ' - Liquid Testnet';
 | 
				
			||||||
    if (this.network === 'bisq')
 | 
					    if (this.network === 'bisq')
 | 
				
			||||||
      return 'mempool - Bisq Markets';
 | 
					      return this.baseTitle + ' - Bisq Markets';
 | 
				
			||||||
    return 'mempool - ' + (this.network ? this.ucfirst(this.network) : 'Bitcoin') + ' Explorer';
 | 
					    return this.baseTitle + ' - ' + (this.network ? this.ucfirst(this.network) : 'Bitcoin') + ' Explorer';
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ucfirst(str: string) {
 | 
					  ucfirst(str: string) {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
‎{{ seconds * 1000 | date:'yyyy-MM-dd HH:mm' }}
 | 
					‎{{ seconds * 1000 | date: customFormat ?? 'yyyy-MM-dd HH:mm' }}
 | 
				
			||||||
<div class="lg-inline">
 | 
					<div class="lg-inline">
 | 
				
			||||||
  <i class="symbol">(<app-time-since [time]="seconds" [fastRender]="true"></app-time-since>)</i>
 | 
					  <i class="symbol">(<app-time-since [time]="seconds" [fastRender]="true"></app-time-since>)</i>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -9,6 +9,7 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/c
 | 
				
			|||||||
export class TimestampComponent implements OnChanges {
 | 
					export class TimestampComponent implements OnChanges {
 | 
				
			||||||
  @Input() unixTime: number;
 | 
					  @Input() unixTime: number;
 | 
				
			||||||
  @Input() dateString: string;
 | 
					  @Input() dateString: string;
 | 
				
			||||||
 | 
					  @Input() customFormat: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  seconds: number;
 | 
					  seconds: number;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -84,3 +84,17 @@ export const download = (href, name) => {
 | 
				
			|||||||
  a.click();
 | 
					  a.click();
 | 
				
			||||||
  document.body.removeChild(a);
 | 
					  document.body.removeChild(a);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function detectWebGL() {
 | 
				
			||||||
 | 
					  const canvas = document.createElement('canvas');
 | 
				
			||||||
 | 
					  const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
 | 
				
			||||||
 | 
					  return (gl && gl instanceof WebGLRenderingContext);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function getFlagEmoji(countryCode) {
 | 
				
			||||||
 | 
					  const codePoints = countryCode
 | 
				
			||||||
 | 
					    .toUpperCase()
 | 
				
			||||||
 | 
					    .split('')
 | 
				
			||||||
 | 
					    .map(char =>  127397 + char.charCodeAt());
 | 
				
			||||||
 | 
					  return String.fromCodePoint(...codePoints);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -7,6 +7,7 @@ import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, fa
 | 
				
			|||||||
  faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode } from '@fortawesome/free-solid-svg-icons';
 | 
					  faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode } from '@fortawesome/free-solid-svg-icons';
 | 
				
			||||||
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
 | 
					import { InfiniteScrollModule } from 'ngx-infinite-scroll';
 | 
				
			||||||
import { MasterPageComponent } from '../components/master-page/master-page.component';
 | 
					import { MasterPageComponent } from '../components/master-page/master-page.component';
 | 
				
			||||||
 | 
					import { MasterPagePreviewComponent } from '../components/master-page-preview/master-page-preview.component';
 | 
				
			||||||
import { BisqMasterPageComponent } from '../components/bisq-master-page/bisq-master-page.component';
 | 
					import { BisqMasterPageComponent } from '../components/bisq-master-page/bisq-master-page.component';
 | 
				
			||||||
import { LiquidMasterPageComponent } from '../components/liquid-master-page/liquid-master-page.component';
 | 
					import { LiquidMasterPageComponent } from '../components/liquid-master-page/liquid-master-page.component';
 | 
				
			||||||
import { AboutComponent } from '../components/about/about.component';
 | 
					import { AboutComponent } from '../components/about/about.component';
 | 
				
			||||||
@ -44,6 +45,8 @@ import { StartComponent } from '../components/start/start.component';
 | 
				
			|||||||
import { TransactionComponent } from '../components/transaction/transaction.component';
 | 
					import { TransactionComponent } from '../components/transaction/transaction.component';
 | 
				
			||||||
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
 | 
					import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
 | 
				
			||||||
import { BlockComponent } from '../components/block/block.component';
 | 
					import { BlockComponent } from '../components/block/block.component';
 | 
				
			||||||
 | 
					import { BlockPreviewComponent } from '../components/block/block-preview.component';
 | 
				
			||||||
 | 
					import { BlockAuditComponent } from '../components/block-audit/block-audit.component';
 | 
				
			||||||
import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component';
 | 
					import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component';
 | 
				
			||||||
import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component';
 | 
					import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component';
 | 
				
			||||||
import { AddressComponent } from '../components/address/address.component';
 | 
					import { AddressComponent } from '../components/address/address.component';
 | 
				
			||||||
@ -109,11 +112,14 @@ import { TimestampComponent } from './components/timestamp/timestamp.component';
 | 
				
			|||||||
    AmountComponent,
 | 
					    AmountComponent,
 | 
				
			||||||
    AboutComponent,
 | 
					    AboutComponent,
 | 
				
			||||||
    MasterPageComponent,
 | 
					    MasterPageComponent,
 | 
				
			||||||
 | 
					    MasterPagePreviewComponent,
 | 
				
			||||||
    BisqMasterPageComponent,
 | 
					    BisqMasterPageComponent,
 | 
				
			||||||
    LiquidMasterPageComponent,
 | 
					    LiquidMasterPageComponent,
 | 
				
			||||||
    StartComponent,
 | 
					    StartComponent,
 | 
				
			||||||
    TransactionComponent,
 | 
					    TransactionComponent,
 | 
				
			||||||
    BlockComponent,
 | 
					    BlockComponent,
 | 
				
			||||||
 | 
					    BlockPreviewComponent,
 | 
				
			||||||
 | 
					    BlockAuditComponent,
 | 
				
			||||||
    BlockOverviewGraphComponent,
 | 
					    BlockOverviewGraphComponent,
 | 
				
			||||||
    BlockOverviewTooltipComponent,
 | 
					    BlockOverviewTooltipComponent,
 | 
				
			||||||
    TransactionsListComponent,
 | 
					    TransactionsListComponent,
 | 
				
			||||||
@ -213,6 +219,8 @@ import { TimestampComponent } from './components/timestamp/timestamp.component';
 | 
				
			|||||||
    StartComponent,
 | 
					    StartComponent,
 | 
				
			||||||
    TransactionComponent,
 | 
					    TransactionComponent,
 | 
				
			||||||
    BlockComponent,
 | 
					    BlockComponent,
 | 
				
			||||||
 | 
					    BlockPreviewComponent,
 | 
				
			||||||
 | 
					    BlockAuditComponent,
 | 
				
			||||||
    BlockOverviewGraphComponent,
 | 
					    BlockOverviewGraphComponent,
 | 
				
			||||||
    BlockOverviewTooltipComponent,
 | 
					    BlockOverviewTooltipComponent,
 | 
				
			||||||
    TransactionsListComponent,
 | 
					    TransactionsListComponent,
 | 
				
			||||||
 | 
				
			|||||||
@ -37,21 +37,5 @@
 | 
				
			|||||||
</head>
 | 
					</head>
 | 
				
			||||||
<body>
 | 
					<body>
 | 
				
			||||||
  <app-root></app-root>
 | 
					  <app-root></app-root>
 | 
				
			||||||
  <script type="text/javascript">
 | 
					 | 
				
			||||||
    if (document.location.hostname === "bisq.markets")
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      var _paq = window._paq = window._paq || [];
 | 
					 | 
				
			||||||
      _paq.push(['disableCookies']);
 | 
					 | 
				
			||||||
      _paq.push(['trackPageView']);
 | 
					 | 
				
			||||||
      _paq.push(['enableLinkTracking']);
 | 
					 | 
				
			||||||
      (function() {
 | 
					 | 
				
			||||||
        var u="//stats.bisq.markets/";
 | 
					 | 
				
			||||||
        _paq.push(['setTrackerUrl', u+'m.php']);
 | 
					 | 
				
			||||||
        _paq.push(['setSiteId', '7']);
 | 
					 | 
				
			||||||
        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
 | 
					 | 
				
			||||||
        g.type='text/javascript'; g.async=true; g.src=u+'m.js'; s.parentNode.insertBefore(g,s);
 | 
					 | 
				
			||||||
      })();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  </script>
 | 
					 | 
				
			||||||
</body>
 | 
					</body>
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
 | 
				
			|||||||
@ -35,21 +35,5 @@
 | 
				
			|||||||
</head>
 | 
					</head>
 | 
				
			||||||
<body>
 | 
					<body>
 | 
				
			||||||
  <app-root></app-root>
 | 
					  <app-root></app-root>
 | 
				
			||||||
  <script type="text/javascript">
 | 
					 | 
				
			||||||
    if (document.location.hostname === "liquid.network")
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      var _paq = window._paq = window._paq || [];
 | 
					 | 
				
			||||||
      _paq.push(['disableCookies']);
 | 
					 | 
				
			||||||
      _paq.push(['trackPageView']);
 | 
					 | 
				
			||||||
      _paq.push(['enableLinkTracking']);
 | 
					 | 
				
			||||||
      (function() {
 | 
					 | 
				
			||||||
        var u="//stats.liquid.network/";
 | 
					 | 
				
			||||||
        _paq.push(['setTrackerUrl', u+'m.php']);
 | 
					 | 
				
			||||||
        _paq.push(['setSiteId', '8']);
 | 
					 | 
				
			||||||
        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
 | 
					 | 
				
			||||||
        g.type='text/javascript'; g.async=true; g.src=u+'m.js'; s.parentNode.insertBefore(g,s);
 | 
					 | 
				
			||||||
      })();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  </script>
 | 
					 | 
				
			||||||
</body>
 | 
					</body>
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
 | 
				
			|||||||
@ -34,21 +34,5 @@
 | 
				
			|||||||
</head>
 | 
					</head>
 | 
				
			||||||
<body>
 | 
					<body>
 | 
				
			||||||
  <app-root></app-root>
 | 
					  <app-root></app-root>
 | 
				
			||||||
  <script type="text/javascript">
 | 
					 | 
				
			||||||
    if (document.location.hostname === "mempool.space")
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      var _paq = window._paq = window._paq || [];
 | 
					 | 
				
			||||||
      _paq.push(['disableCookies']);
 | 
					 | 
				
			||||||
      _paq.push(['trackPageView']);
 | 
					 | 
				
			||||||
      _paq.push(['enableLinkTracking']);
 | 
					 | 
				
			||||||
      (function() {
 | 
					 | 
				
			||||||
        var u="//stats.mempool.space/";
 | 
					 | 
				
			||||||
        _paq.push(['setTrackerUrl', u+'m.php']);
 | 
					 | 
				
			||||||
        _paq.push(['setSiteId', '5']);
 | 
					 | 
				
			||||||
        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
 | 
					 | 
				
			||||||
        g.type='text/javascript'; g.async=true; g.src=u+'m.js'; s.parentNode.insertBefore(g,s);
 | 
					 | 
				
			||||||
      })();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  </script>
 | 
					 | 
				
			||||||
</body>
 | 
					</body>
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										1
									
								
								frontend/src/resources/worldmap.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/resources/worldmap.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@ -70,6 +70,30 @@ location /api/v1/translators {
 | 
				
			|||||||
	proxy_hide_header content-security-policy;
 | 
						proxy_hide_header content-security-policy;
 | 
				
			||||||
	proxy_hide_header x-frame-options;
 | 
						proxy_hide_header x-frame-options;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					location /api/v1/enterprise/images {
 | 
				
			||||||
 | 
						proxy_pass $mempoolSpaceServices;
 | 
				
			||||||
 | 
						proxy_cache services;
 | 
				
			||||||
 | 
						proxy_cache_background_update on;
 | 
				
			||||||
 | 
						proxy_cache_use_stale updating;
 | 
				
			||||||
 | 
						proxy_cache_valid 200 10m;
 | 
				
			||||||
 | 
						expires 10m;
 | 
				
			||||||
 | 
						proxy_hide_header onion-location;
 | 
				
			||||||
 | 
						proxy_hide_header strict-transport-security;
 | 
				
			||||||
 | 
						proxy_hide_header content-security-policy;
 | 
				
			||||||
 | 
						proxy_hide_header x-frame-options;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					location /api/v1/enterprise {
 | 
				
			||||||
 | 
						proxy_pass $mempoolSpaceServices;
 | 
				
			||||||
 | 
						proxy_cache services;
 | 
				
			||||||
 | 
						proxy_cache_background_update on;
 | 
				
			||||||
 | 
						proxy_cache_use_stale updating;
 | 
				
			||||||
 | 
						proxy_cache_valid 200 5m;
 | 
				
			||||||
 | 
						expires 5m;
 | 
				
			||||||
 | 
						proxy_hide_header onion-location;
 | 
				
			||||||
 | 
						proxy_hide_header strict-transport-security;
 | 
				
			||||||
 | 
						proxy_hide_header content-security-policy;
 | 
				
			||||||
 | 
						proxy_hide_header x-frame-options;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
location /api/v1/assets {
 | 
					location /api/v1/assets {
 | 
				
			||||||
	proxy_pass $mempoolSpaceServices;
 | 
						proxy_pass $mempoolSpaceServices;
 | 
				
			||||||
	proxy_cache services;
 | 
						proxy_cache services;
 | 
				
			||||||
 | 
				
			|||||||
@ -46,13 +46,13 @@ add_header Vary Cookie;
 | 
				
			|||||||
# https://stackoverflow.com/questions/5238377/nginx-location-priority
 | 
					# https://stackoverflow.com/questions/5238377/nginx-location-priority
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# for exact / requests, redirect based on $lang
 | 
					# for exact / requests, redirect based on $lang
 | 
				
			||||||
# cache redirect for 10 minutes
 | 
					# cache redirect for 5 minutes
 | 
				
			||||||
location = / {
 | 
					location = / {
 | 
				
			||||||
	if ($lang != '') {
 | 
						if ($lang != '') {
 | 
				
			||||||
		return 302 $scheme://$host/$lang/;
 | 
							return 302 $scheme://$host/$lang/;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	try_files /en-US/index.html =404;
 | 
						try_files /en-US/index.html =404;
 | 
				
			||||||
	expires 10m;
 | 
						expires 5m;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# used to rewrite resources from /<lang>/ to /en-US/
 | 
					# used to rewrite resources from /<lang>/ to /en-US/
 | 
				
			||||||
@ -66,14 +66,14 @@ location ~ ^/([a-z][a-z])/(.+\..+\.(js|css)) {
 | 
				
			|||||||
	try_files $uri =404;
 | 
						try_files $uri =404;
 | 
				
			||||||
	expires 1y;
 | 
						expires 1y;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
# cache everything else for 10 minutes
 | 
					# cache everything else for 5 minutes
 | 
				
			||||||
location ~ ^/([a-z][a-z])$ {
 | 
					location ~ ^/([a-z][a-z])$ {
 | 
				
			||||||
	try_files $uri /$1/index.html /en-US/index.html =404;
 | 
						try_files $uri /$1/index.html /en-US/index.html =404;
 | 
				
			||||||
        expires 10m;
 | 
					        expires 5m;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
location ~ ^/([a-z][a-z])/ {
 | 
					location ~ ^/([a-z][a-z])/ {
 | 
				
			||||||
	try_files $uri /$1/index.html /en-US/index.html =404;
 | 
						try_files $uri /$1/index.html /en-US/index.html =404;
 | 
				
			||||||
        expires 10m;
 | 
					        expires 5m;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# cache /resources/** for 1 week since they don't change often
 | 
					# cache /resources/** for 1 week since they don't change often
 | 
				
			||||||
@ -87,8 +87,8 @@ location ~* ^/.+\..+\.(js|css) {
 | 
				
			|||||||
	expires 1y;
 | 
						expires 1y;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
 | 
					# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
 | 
				
			||||||
# cache 10 minutes since they change frequently
 | 
					# cache 5 minutes since they change frequently
 | 
				
			||||||
location / {
 | 
					location / {
 | 
				
			||||||
	try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
 | 
						try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
 | 
				
			||||||
	expires 10m;
 | 
						expires 5m;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user