Tooltip-style tx previews in block overview

This commit is contained in:
Mononaut 2022-06-15 01:40:05 +00:00
parent 300f5375c8
commit 2d529bd581
12 changed files with 204 additions and 73 deletions

View File

@ -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>

View File

@ -18,6 +18,10 @@
width: 100%;
height: 100%;
overflow: hidden;
&.clickable {
cursor: pointer;
}
}
.loader-wrapper {

View File

@ -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

View File

@ -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> &nbsp; <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]="'&lrm;' + (vsize | vbytes: 2)"></td>
</tr>
</tbody>
</table>
</div>

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -249,6 +249,7 @@
[blockLimit]="stateService.blockVSize"
[orientation]="'top'"
[flip]="false"
(txClickEvent)="onTxClick($event)"
></app-block-overview-graph>
</div>
</div>

View File

@ -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() {

View File

@ -5,5 +5,5 @@
[blockLimit]="stateService.blockVSize"
[orientation]="'left'"
[flip]="true"
(txPreviewEvent)="onTxPreview($event)"
(txClickEvent)="onTxClick($event)"
></app-block-overview-graph>

View File

@ -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]);
}
}

View File

@ -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]="'&lrm;' + (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>

View File

@ -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,