Tooltip-style tx previews in block overview
This commit is contained in:
		
							parent
							
								
									300f5375c8
								
							
						
					
					
						commit
						2d529bd581
					
				| @ -1,6 +1,12 @@ | ||||
| <div class="block-overview-graph"> | ||||
|   <canvas class="block-overview-canvas" #blockCanvas></canvas> | ||||
|   <canvas class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas> | ||||
|   <div class="loader-wrapper" [class.hidden]="!isLoading"> | ||||
|     <div class="spinner-border ml-3 loading" role="status"></div> | ||||
|   </div> | ||||
| 
 | ||||
|   <app-block-overview-tooltip | ||||
|     [tx]="selectedTx || hoverTx" | ||||
|     [cursorPosition]="tooltipPosition" | ||||
|     [clickable]="!!selectedTx" | ||||
|   ></app-block-overview-tooltip> | ||||
| </div> | ||||
|  | ||||
| @ -18,6 +18,10 @@ | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   overflow: hidden; | ||||
| 
 | ||||
|   &.clickable { | ||||
|     cursor: pointer; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .loader-wrapper { | ||||
|  | ||||
| @ -5,6 +5,7 @@ import { FastVertexArray } from './fast-vertex-array'; | ||||
| import BlockScene from './block-scene'; | ||||
| import TxSprite from './tx-sprite'; | ||||
| import TxView from './tx-view'; | ||||
| import { Position } from './sprite-types'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-overview-graph', | ||||
| @ -17,7 +18,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { | ||||
|   @Input() blockLimit: number; | ||||
|   @Input() orientation = 'left'; | ||||
|   @Input() flip = true; | ||||
|   @Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>(); | ||||
|   @Output() txClickEvent = new EventEmitter<TransactionStripped>(); | ||||
| 
 | ||||
|   @ViewChild('blockCanvas') | ||||
|   canvas: ElementRef<HTMLCanvasElement>; | ||||
| @ -35,9 +36,11 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { | ||||
|   scene: BlockScene; | ||||
|   hoverTx: TxView | void; | ||||
|   selectedTx: TxView | void; | ||||
|   tooltipPosition: Position; | ||||
| 
 | ||||
|   constructor( | ||||
|     readonly ngZone: NgZone, | ||||
|     readonly elRef: ElementRef, | ||||
|   ) { | ||||
|     this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); | ||||
|   } | ||||
| @ -62,7 +65,6 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { | ||||
|     this.exit(direction); | ||||
|     this.hoverTx = null; | ||||
|     this.selectedTx = null; | ||||
|     this.txPreviewEvent.emit(null); | ||||
|     this.start(); | ||||
|   } | ||||
| 
 | ||||
| @ -257,25 +259,50 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('click', ['$event']) | ||||
|   @HostListener('document:click', ['$event']) | ||||
|   clickAway(event) { | ||||
|     if (!this.elRef.nativeElement.contains(event.target)) { | ||||
|       const currentPreview = this.selectedTx || this.hoverTx; | ||||
|       if (currentPreview && this.scene) { | ||||
|         this.scene.setHover(currentPreview, false); | ||||
|         this.start(); | ||||
|       } | ||||
|       this.hoverTx = null; | ||||
|       this.selectedTx = null; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('pointerup', ['$event']) | ||||
|   onClick(event) { | ||||
|     this.setPreviewTx(event.offsetX, event.offsetY, true); | ||||
|     if (event.target === this.canvas.nativeElement && event.pointerType === 'touch') { | ||||
|       this.setPreviewTx(event.offsetX, event.offsetY, true); | ||||
|     } else if (event.target === this.canvas.nativeElement) { | ||||
|       this.onTxClick(event.offsetX, event.offsetY); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('pointermove', ['$event']) | ||||
|   onPointerMove(event) { | ||||
|     this.setPreviewTx(event.offsetX, event.offsetY, false); | ||||
|     if (event.target === this.canvas.nativeElement) { | ||||
|       this.setPreviewTx(event.offsetX, event.offsetY, false); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('pointerleave', ['$event']) | ||||
|   onPointerLeave(event) { | ||||
|     this.setPreviewTx(-1, -1, false); | ||||
|     if (event.pointerType !== 'touch') { | ||||
|       this.setPreviewTx(-1, -1, true); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setPreviewTx(cssX: number, cssY: number, clicked: boolean = false) { | ||||
|     const x = cssX * window.devicePixelRatio; | ||||
|     const y = cssY * window.devicePixelRatio; | ||||
|     if (this.scene && (!this.selectedTx || clicked)) { | ||||
|       this.tooltipPosition = { | ||||
|         x: cssX, | ||||
|         y: cssY | ||||
|       }; | ||||
|       const selected = this.scene.getTxAt({ x, y }); | ||||
|       const currentPreview = this.selectedTx || this.hoverTx; | ||||
| 
 | ||||
| @ -289,12 +316,6 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { | ||||
|             this.scene.setHover(selected, true); | ||||
|             this.start(); | ||||
|           } | ||||
|           this.txPreviewEvent.emit({ | ||||
|             txid: selected.txid, | ||||
|             fee: selected.fee, | ||||
|             vsize: selected.vsize, | ||||
|             value: selected.value | ||||
|           }); | ||||
|           if (clicked) { | ||||
|             this.selectedTx = selected; | ||||
|           } else { | ||||
| @ -305,7 +326,6 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { | ||||
|             this.selectedTx = null; | ||||
|           } | ||||
|           this.hoverTx = null; | ||||
|           this.txPreviewEvent.emit(null); | ||||
|         } | ||||
|       } else if (clicked) { | ||||
|         if (selected === this.selectedTx) { | ||||
| @ -317,6 +337,15 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy { | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onTxClick(cssX: number, cssY: number) { | ||||
|     const x = cssX * window.devicePixelRatio; | ||||
|     const y = cssY * window.devicePixelRatio; | ||||
|     const selected = this.scene.getTxAt({ x, y }); | ||||
|     if (selected && selected.txid) { | ||||
|       this.txClickEvent.emit(selected); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // WebGL shader attributes
 | ||||
|  | ||||
| @ -0,0 +1,37 @@ | ||||
| <div | ||||
|   #tooltip | ||||
|   class="block-overview-tooltip" | ||||
|   [class.clickable]="clickable" | ||||
|   [style.visibility]="tx ? 'visible' : 'hidden'" | ||||
|   [style.left]="tooltipPosition.x + 'px'" | ||||
|   [style.top]="tooltipPosition.y + 'px'" | ||||
| > | ||||
|   <table> | ||||
|     <tbody> | ||||
|       <tr> | ||||
|         <td i18n="shared.transaction">Transaction</td> | ||||
|         <td> | ||||
|           <a [routerLink]="['/tx/' | relativeUrl, txid]">{{ txid | shortenString : 16}}</a> | ||||
|         </td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.value|Transaction value">Value</td> | ||||
|         <td><app-amount [satoshis]="value"></app-amount></td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td> | ||||
|         <td>{{ fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span>   <span class="fiat"><app-fiat [value]="fee"></app-fiat></span></td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td> | ||||
|         <td> | ||||
|           {{ feeRate | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> | ||||
|         </td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td> | ||||
|         <td [innerHTML]="'‎' + (vsize | vbytes: 2)"></td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| </div> | ||||
| @ -0,0 +1,18 @@ | ||||
| .block-overview-tooltip { | ||||
|   position: absolute; | ||||
|   background: rgba(#11131f, 0.95); | ||||
|   border-radius: 4px; | ||||
|   box-shadow: 1px 1px 10px rgba(0,0,0,0.5); | ||||
|   color: #b1b1b1; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   justify-content: space-between; | ||||
|   padding: 10px 15px; | ||||
|   text-align: left; | ||||
|   width: 320px; | ||||
|   pointer-events: none; | ||||
| 
 | ||||
|   &.clickable { | ||||
|     pointer-events: all; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,53 @@ | ||||
| import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core'; | ||||
| import { TransactionStripped } from 'src/app/interfaces/websocket.interface'; | ||||
| import { Position } from 'src/app/components/block-overview-graph/sprite-types.js'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-overview-tooltip', | ||||
|   templateUrl: './block-overview-tooltip.component.html', | ||||
|   styleUrls: ['./block-overview-tooltip.component.scss'], | ||||
| }) | ||||
| export class BlockOverviewTooltipComponent implements OnChanges { | ||||
|   @Input() tx: TransactionStripped | void; | ||||
|   @Input() cursorPosition: Position; | ||||
|   @Input() clickable: boolean; | ||||
| 
 | ||||
|   txid = ''; | ||||
|   fee = 0; | ||||
|   value = 0; | ||||
|   vsize = 1; | ||||
|   feeRate = 0; | ||||
| 
 | ||||
|   tooltipPosition: Position = { x: 0, y: 0 }; | ||||
| 
 | ||||
|   @ViewChild('tooltip') tooltipElement: ElementRef<HTMLCanvasElement>; | ||||
| 
 | ||||
|   constructor() {} | ||||
| 
 | ||||
|   ngOnChanges(changes): void { | ||||
|     if (changes.cursorPosition && changes.cursorPosition.currentValue) { | ||||
|       let x = changes.cursorPosition.currentValue.x + 10; | ||||
|       let y = changes.cursorPosition.currentValue.y + 10; | ||||
|       if (this.tooltipElement) { | ||||
|         const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect(); | ||||
|         const parentBounds = this.tooltipElement.nativeElement.offsetParent.getBoundingClientRect(); | ||||
|         if ((parentBounds.left + x + elementBounds.width) > parentBounds.right) { | ||||
|           x = Math.max(0, parentBounds.width - elementBounds.width - 10); | ||||
|         } | ||||
|         if (y + elementBounds.height > parentBounds.height) { | ||||
|           y = y - elementBounds.height - 20; | ||||
|         } | ||||
|       } | ||||
|       this.tooltipPosition = { x, y }; | ||||
|     } | ||||
| 
 | ||||
|     if (changes.tx) { | ||||
|       const tx = changes.tx.currentValue || {}; | ||||
|       this.txid = tx.txid || ''; | ||||
|       this.fee = tx.fee || 0; | ||||
|       this.value = tx.value || 0; | ||||
|       this.vsize = tx.vsize || 1; | ||||
|       this.feeRate = this.fee / this.vsize; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -249,6 +249,7 @@ | ||||
|           [blockLimit]="stateService.blockVSize" | ||||
|           [orientation]="'top'" | ||||
|           [flip]="false" | ||||
|           (txClickEvent)="onTxClick($event)" | ||||
|         ></app-block-overview-graph> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
| @ -367,6 +367,11 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onTxClick(event: TransactionStripped): void { | ||||
|     const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); | ||||
|     this.router.navigate([url]); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function detectWebGL() { | ||||
|  | ||||
| @ -5,5 +5,5 @@ | ||||
|   [blockLimit]="stateService.blockVSize" | ||||
|   [orientation]="'left'" | ||||
|   [flip]="true" | ||||
|   (txPreviewEvent)="onTxPreview($event)" | ||||
|   (txClickEvent)="onTxClick($event)" | ||||
| ></app-block-overview-graph> | ||||
|  | ||||
| @ -6,6 +6,8 @@ import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-g | ||||
| import { Subscription, BehaviorSubject, merge, of } from 'rxjs'; | ||||
| import { switchMap, filter } from 'rxjs/operators'; | ||||
| import { WebsocketService } from 'src/app/services/websocket.service'; | ||||
| import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { Router } from '@angular/router'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-mempool-block-overview', | ||||
| @ -27,7 +29,8 @@ export class MempoolBlockOverviewComponent implements OnDestroy, OnChanges, Afte | ||||
| 
 | ||||
|   constructor( | ||||
|     public stateService: StateService, | ||||
|     private websocketService: WebsocketService | ||||
|     private websocketService: WebsocketService, | ||||
|     private router: Router, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngAfterViewInit(): void { | ||||
| @ -89,7 +92,8 @@ export class MempoolBlockOverviewComponent implements OnDestroy, OnChanges, Afte | ||||
|     this.isLoading$.next(false); | ||||
|   } | ||||
| 
 | ||||
|   onTxPreview(event: TransactionStripped | void): void { | ||||
|     this.txPreviewEvent.emit(event); | ||||
|   onTxClick(event: TransactionStripped): void { | ||||
|     const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); | ||||
|     this.router.navigate([url]); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -10,62 +10,33 @@ | ||||
|   <div class="box"> | ||||
|     <div class="row"> | ||||
|       <div class="col-md"> | ||||
|         <table class="table table-borderless table-striped table-fixed"> | ||||
|         <table class="table table-borderless table-striped"> | ||||
|           <tbody> | ||||
|             <ng-container *ngIf="!previewTx"> | ||||
|               <tr> | ||||
|                 <td i18n="mempool-block.median-fee">Median fee</td> | ||||
|                 <td>~{{ mempoolBlock.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="mempoolBlock.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="mempool-block.fee-span">Fee span</td> | ||||
|                 <td><span class="yellow-color">{{ mempoolBlock.feeRange[0] | number:'1.0-0' }} - {{ mempoolBlock.feeRange[mempoolBlock.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|                 <td><app-amount [satoshis]="mempoolBlock.totalFees" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> <span class="fiat"><app-fiat [value]="mempoolBlock.totalFees" digitsInfo="1.0-0"></app-fiat></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="mempool-block.transactions">Transactions</td> | ||||
|                 <td>{{ mempoolBlock.nTx }}</td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="mempool-block.size">Size</td> | ||||
|                 <td> | ||||
|                   <div class="progress"> | ||||
|                     <div class="progress-bar progress-mempool {{ (network$ | async) }}" role="progressbar" [ngStyle]="{'width': (mempoolBlock.blockVSize / stateService.blockVSize) * 100 + '%' }"></div> | ||||
|                     <div class="progress-text" [innerHTML]="mempoolBlock.blockSize | bytes: 2"></div> | ||||
|                   </div> | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </ng-container> | ||||
| 
 | ||||
|             <ng-container *ngIf="previewTx"> | ||||
|               <tr> | ||||
|                 <td i18n="shared.transaction">Transaction</td> | ||||
|                 <td> | ||||
|                   <a [routerLink]="['/tx/' | relativeUrl, previewTx.txid]">{{ previewTx.txid | shortenString : 16}}</a> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td class="td-width" i18n="transaction.value|Transaction value">Value</td> | ||||
|                 <td><app-amount [satoshis]="previewTx.value"></app-amount></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td> | ||||
|                 <td>{{ previewTx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> <span class="fiat"><app-fiat [value]="previewTx.fee"></app-fiat></span></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td> | ||||
|                 <td> | ||||
|                   {{ (previewTx.fee / previewTx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td> | ||||
|                 <td [innerHTML]="'‎' + (previewTx.vsize | vbytes: 2)"></td> | ||||
|               </tr> | ||||
|               </ng-container> | ||||
|             <tr> | ||||
|               <td i18n="mempool-block.median-fee">Median fee</td> | ||||
|               <td>~{{ mempoolBlock.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="mempoolBlock.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td i18n="mempool-block.fee-span">Fee span</td> | ||||
|               <td><span class="yellow-color">{{ mempoolBlock.feeRange[0] | number:'1.0-0' }} - {{ mempoolBlock.feeRange[mempoolBlock.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td i18n="block.total-fees|Total fees in a block">Total fees</td> | ||||
|               <td><app-amount [satoshis]="mempoolBlock.totalFees" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> <span class="fiat"><app-fiat [value]="mempoolBlock.totalFees" digitsInfo="1.0-0"></app-fiat></span></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td i18n="mempool-block.transactions">Transactions</td> | ||||
|               <td>{{ mempoolBlock.nTx }}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td i18n="mempool-block.size">Size</td> | ||||
|               <td> | ||||
|                 <div class="progress"> | ||||
|                   <div class="progress-bar progress-mempool {{ (network$ | async) }}" role="progressbar" [ngStyle]="{'width': (mempoolBlock.blockVSize / stateService.blockVSize) * 100 + '%' }"></div> | ||||
|                   <div class="progress-text" [innerHTML]="mempoolBlock.blockSize | bytes: 2"></div> | ||||
|                 </div> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|         <app-fee-distribution-graph *ngIf="webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph> | ||||
|  | ||||
| @ -46,6 +46,7 @@ import { TransactionComponent } from '../components/transaction/transaction.comp | ||||
| import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; | ||||
| import { BlockComponent } from '../components/block/block.component'; | ||||
| import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; | ||||
| import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; | ||||
| import { AddressComponent } from '../components/address/address.component'; | ||||
| import { SearchFormComponent } from '../components/search-form/search-form.component'; | ||||
| import { AddressLabelsComponent } from '../components/address-labels/address-labels.component'; | ||||
| @ -112,6 +113,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | ||||
|     TransactionComponent, | ||||
|     BlockComponent, | ||||
|     BlockOverviewGraphComponent, | ||||
|     BlockOverviewTooltipComponent, | ||||
|     TransactionsListComponent, | ||||
|     AddressComponent, | ||||
|     SearchFormComponent, | ||||
| @ -206,6 +208,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | ||||
|     TransactionComponent, | ||||
|     BlockComponent, | ||||
|     BlockOverviewGraphComponent, | ||||
|     BlockOverviewTooltipComponent, | ||||
|     TransactionsListComponent, | ||||
|     AddressComponent, | ||||
|     SearchFormComponent, | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user