diff --git a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html index 59dbe36de4..1848d1456d 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html +++ b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html @@ -48,10 +48,12 @@ [class.inactive]="outline.chunkIndex !== effectiveActiveChunk" [attr.d]="outline.path" [ngbTooltip]="chunkLabelTooltip" + [disableTooltip]="isMobile" container="body" placement="top" (mouseenter)="onChunkEnter(outline.chunkIndex)" - (mouseleave)="onChunkLeave()" /> + (mouseleave)="onChunkLeave()" + (click)="onChunkClick(outline.chunkIndex, $event)" /> + (mouseleave)="onChunkLeave()" + (click)="onChunkClick(outline.chunkIndex, $event)"> {{ outline.feerate | feeRounding }} sat/vB @@ -76,7 +80,8 @@ stroke-width="12" (mouseenter)="onEdgeEnter(i, $event)" (mousemove)="onEdgeMove($event)" - (mouseleave)="onEdgeLeave()" /> + (mouseleave)="onEdgeLeave()" + (click)="onEdgeClick(i, $event)" /> + (click)="onNodeClick(node, $event)"> @@ -154,3 +159,39 @@ child + + + + + {{ hoverNode.tx.txid | shortenString }} + this transaction + + + + + Fee + {{ hoverNode.tx.fee | number }} sats + + + Size + + + + Fee rate + {{ hoverNode.feerate | feeRounding }} sat/vB + + + + parents + children + + + + + parent + child + + diff --git a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss index 603a5f9995..18d716f947 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss +++ b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss @@ -144,17 +144,14 @@ font-weight: 700; } -.cluster-tooltip { - position: absolute; +.cluster-tooltip, +.cluster-mobile-panel { background: color-mix(in srgb, var(--active-bg) 95%, transparent); border-radius: 4px; box-shadow: 1px 1px 10px rgba(0, 0, 0, 0.5); color: var(--tooltip-grey); padding: 8px 12px; text-align: left; - pointer-events: none; - max-width: 360px; - white-space: nowrap; .tx-id-row { display: flex; @@ -225,3 +222,15 @@ &.descendant { background: var(--cluster-descendant-color); } } } + +.cluster-tooltip { + position: absolute; + pointer-events: none; + max-width: 360px; + white-space: nowrap; +} + +.cluster-mobile-panel { + margin-top: 8px; + white-space: normal; +} diff --git a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts index df14efcba8..070d3e46b6 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts +++ b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts @@ -22,6 +22,7 @@ export class ClusterDiagramComponent implements OnChanges, AfterViewInit, OnDest @Input() cluster: { txs: CpfpClusterTx[]; chunks: CpfpClusterChunk[]; chunkIndex: number }; @Input() txid: string; @Input() preview = false; + @Input() isMobile = false; @ViewChild('graphContainer', { static: true }) graphContainer: ElementRef; @ViewChild('tooltip') tooltipElement: ElementRef; @@ -125,8 +126,17 @@ export class ClusterDiagramComponent implements OnChanges, AfterViewInit, OnDest } onNodeEnter(node: RenderedNode, event: MouseEvent): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } + this.applyNodeHighlight(node); + this.updateTooltipPosition(event); + this.cd.markForCheck(); + } + + private applyNodeHighlight(node: RenderedNode): void { this.hoverNode = node; + this.hoverEdge = null; + this.hoverChunkIndex = null; + this.applyEffectiveChunk(); this.clearHighlights(); node.hovered = true; for (const edge of this.edges) { @@ -140,58 +150,63 @@ export class ClusterDiagramComponent implements OnChanges, AfterViewInit, OnDest edge.highlightKind = 'ancestor'; } } - this.updateTooltipPosition(event); - this.cd.markForCheck(); } onNodeMove(event: MouseEvent): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.updateTooltipPosition(event); this.cd.markForCheck(); } onNodeLeave(): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.hoverNode = null; this.clearHighlights(); this.cd.markForCheck(); } onEdgeEnter(edgeIndex: number, event: MouseEvent): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } + this.applyEdgeHighlight(edgeIndex); + this.updateTooltipPosition(event); + this.cd.markForCheck(); + } + + private applyEdgeHighlight(edgeIndex: number): void { this.clearHighlights(); + this.hoverNode = null; + this.hoverChunkIndex = null; + this.applyEffectiveChunk(); const edge = this.edges[edgeIndex]; edge.highlighted = true; edge.highlightKind = 'direct'; this.nodes[edge.parentIndex].relation = 'ancestor'; this.nodes[edge.childIndex].relation = 'descendant'; this.hoverEdge = edge; - this.updateTooltipPosition(event); - this.cd.markForCheck(); } onEdgeMove(event: MouseEvent): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.updateTooltipPosition(event); this.cd.markForCheck(); } onEdgeLeave(): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.hoverEdge = null; this.clearHighlights(); this.cd.markForCheck(); } onChunkEnter(chunkIndex: number): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.hoverChunkIndex = chunkIndex; this.applyEffectiveChunk(); this.cd.markForCheck(); } onChunkLeave(): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.hoverChunkIndex = null; this.applyEffectiveChunk(); this.cd.markForCheck(); @@ -212,13 +227,61 @@ export class ClusterDiagramComponent implements OnChanges, AfterViewInit, OnDest } } - onNodeClick(node: RenderedNode): void { + onNodeClick(node: RenderedNode, event: MouseEvent): void { if (this.preview) { return; } + if (this.isMobile) { + event.stopPropagation(); + if (this.hoverNode?.index === node.index) { + this.clearMobileSelection(); + } else { + this.applyNodeHighlight(node); + this.cd.markForCheck(); + } + return; + } const network = this.stateService.network; const prefix = network && network !== 'mainnet' ? `/${network}` : ''; this.router.navigate([prefix + '/tx/', node.tx.txid]); } + onEdgeClick(edgeIndex: number, event: MouseEvent): void { + if (this.preview || !this.isMobile) { return; } + event.stopPropagation(); + const edge = this.edges[edgeIndex]; + if (this.hoverEdge === edge) { + this.clearMobileSelection(); + } else { + this.applyEdgeHighlight(edgeIndex); + this.cd.markForCheck(); + } + } + + onChunkClick(chunkIndex: number, event: MouseEvent): void { + if (this.preview || !this.isMobile) { return; } + event.stopPropagation(); + this.hoverNode = null; + this.hoverEdge = null; + this.clearHighlights(); + this.hoverChunkIndex = chunkIndex; + this.applyEffectiveChunk(); + this.cd.markForCheck(); + } + + @HostListener('click') + onBackgroundClick(): void { + if (this.preview || !this.isMobile) { return; } + this.clearMobileSelection(); + } + + private clearMobileSelection(): void { + this.hoverNode = null; + this.hoverEdge = null; + this.hoverChunkIndex = null; + this.clearHighlights(); + this.applyEffectiveChunk(); + this.cd.markForCheck(); + } + private clearHighlights(): void { for (const node of this.nodes) { node.hovered = false; diff --git a/frontend/src/app/components/cluster-diagram/cluster-renderer.ts b/frontend/src/app/components/cluster-diagram/cluster-renderer.ts index 69f30d580d..8ccefbd1c7 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-renderer.ts +++ b/frontend/src/app/components/cluster-diagram/cluster-renderer.ts @@ -121,6 +121,7 @@ const PREVIEW_DIMENSIONS: RenderDimensions = { }; const PREVIEW_VIEWPORT_HEIGHT = 48; +const PREVIEW_SAFE_INSET = 16; const OUTLINE_PAD = 6; export function renderLayout(layout: GridLayout, params: RenderParams): RenderResult { @@ -175,7 +176,7 @@ export function renderLayout(layout: GridLayout, params: RenderParams): RenderRe let cellW: number; if (params.preview) { const activeCols = activeChunkColCount(layout, params.activeChunkIndex); - const denom = Math.max(1, activeCols); + const denom = Math.max(1, activeCols + 1); cellW = Math.max(dim.minCellW, Math.min(dim.maxCellW, (params.containerWidth - dim.marginX * 2) / denom)); } else if (layout.cols > 0) { @@ -222,15 +223,21 @@ export function renderLayout(layout: GridLayout, params: RenderParams): RenderRe activeMaxY = Math.max(activeMaxY, n.rectY + n.height); } } - const pad = dim.marginX; + const pad = dim.marginX + PREVIEW_SAFE_INSET; const chunkFits = isFinite(activeMinX) && (activeMaxX - activeMinX) + 2 * pad <= vbWidth; - const vbX = chunkFits + let vbX = chunkFits ? (activeMinX + activeMaxX) / 2 - vbWidth / 2 : selected.x - vbWidth / 2; + if (!chunkFits && isFinite(activeMinX)) { + vbX = Math.max(activeMinX - pad, Math.min(activeMaxX + pad - vbWidth, vbX)); + } const chunkFitsVertically = isFinite(activeMinY) && (activeMaxY - activeMinY) + 2 * dim.marginY <= vbHeight; - const vbY = chunkFitsVertically + let vbY = chunkFitsVertically ? (activeMinY + activeMaxY) / 2 - vbHeight / 2 : selected.y - vbHeight / 2; + if (!chunkFitsVertically && isFinite(activeMinY)) { + vbY = Math.max(activeMinY - dim.marginY, Math.min(activeMaxY + dim.marginY - vbHeight, vbY)); + } return { nodes, edges, chunkOutlines, diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html index f6decfacf4..41951982bb 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html @@ -248,13 +248,14 @@ - + diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index 228aedcb9a..ee22420992 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -104,7 +104,7 @@ + [cluster]="cpfpInfo.cluster" [txid]="transaction.txid" [isMobile]="isMobile"> @@ -248,4 +248,4 @@ } - \ No newline at end of file + diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index 625cbf6057..4886f86a96 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -90,7 +90,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.seoService.setTitle($localize`:@@d7f92e6fe26fba6fff568cbdae5db4a5c8c6a55c:Preview Transaction`); this.seoService.setDescription($localize`:@@meta.description.preview-tx:Preview a transaction to the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network using the transaction's raw hex data.`); this.websocketService.want(['blocks', 'mempool-blocks']); - this.cpfpMode = this.isCpfpParamEnabled(this.route.snapshot.queryParams['cpfp']); + this.cpfpMode = this.route.snapshot.queryParams['cpfp'] === 'true'; this.pushTxForm = this.formBuilder.group({ txRaw: ['', Validators.required], }); @@ -380,10 +380,6 @@ export class TransactionRawComponent implements OnInit, OnDestroy { } } - private isCpfpParamEnabled(cpfpParam: string | undefined): boolean { - return cpfpParam === 'true' || cpfpParam === 'advanced' || cpfpParam === 'simple'; - } - setupGraph() { this.maxInOut = Math.min(this.inOutLimit, Math.max(this.transaction?.vin?.length || 1, this.transaction?.vout?.length + 1 || 1)); this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80); diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index bc91457594..800d3b54e4 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -82,7 +82,6 @@ Transaction - Cluster @@ -93,7 +92,7 @@ + [cluster]="cpfpInfo.cluster" [txid]="tx.txid" [isMobile]="isMobile"> @@ -394,4 +393,4 @@ Waiting for it to appear i - \ No newline at end of file + diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 7db51e9834..459914f91d 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -173,6 +173,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { graphContainer: ElementRef; private txList: TransactionsListComponent; + private fragmentAnchor: string | null = null; + private scrolledFragmentAnchor: string | null = null; + private firstFragmentScroll = true; @ViewChild('txList') set txListSetter(component: TransactionsListComponent | undefined) { @@ -197,13 +200,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } } - @ViewChild('cluster') - set clusterAnchor(element: ElementRef | null | undefined) { - if (element) { - setTimeout(() => { this.applyFragment(); }, 0); - } - } - constructor( private route: ActivatedRoute, private router: Router, @@ -228,7 +224,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { ngOnInit() { this.enterpriseService.page(); this.isDetailsOpen = this.route.snapshot.queryParams['showDetails'] === 'true'; - this.cpfpMode = this.isCpfpParamEnabled(this.route.snapshot.queryParams['cpfp']); + this.cpfpMode = this.route.snapshot.queryParams['cpfp'] === 'true'; const urlParams = new URLSearchParams(window.location.search); this.forceAccelerationSummary = !!urlParams.get('cash_request_id'); @@ -609,7 +605,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], { queryParamsHandling: 'merge', - fragment: this.fragmentParams.toString(), + fragment: this.formatFragment(this.fragmentParams), }); } else { this.txId = urlMatch[0]; @@ -620,7 +616,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.fragmentParams.delete('vin'); this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], { queryParamsHandling: 'merge', - fragment: this.fragmentParams.toString(), + fragment: this.formatFragment(this.fragmentParams), }); } } @@ -630,7 +626,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { if (window.innerWidth <= 767.98) { this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], { queryParamsHandling: 'merge', - preserveFragment: true, + fragment: this.formatFragment(this.fragmentParams, this.fragmentAnchor), queryParams: { mode: 'details' }, replaceUrl: true, }); @@ -888,7 +884,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { relativeTo: this.route, queryParams: { showDetails: this.isDetailsOpen ? 'true' : null }, queryParamsHandling: 'merge', - preserveFragment: true, + fragment: this.formatFragment(this.fragmentParams), replaceUrl: true, }); this.txList?.setDetailsOpen(this.isDetailsOpen); @@ -1081,6 +1077,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { resetTransaction() { this.firstLoad = false; + this.firstFragmentScroll = this.fragmentAnchor !== null; + this.scrolledFragmentAnchor = null; this.gotInitialPosition = false; this.error = undefined; this.tx = null; @@ -1108,7 +1106,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.auditStatus = null; this.accelerationPositions = null; this.isDetailsOpen = this.route.snapshot.queryParams['showDetails'] === 'true'; - this.cpfpMode = this.isCpfpParamEnabled(this.route.snapshot.queryParams['cpfp']); + this.cpfpMode = this.route.snapshot.queryParams['cpfp'] === 'true'; document.body.scrollTo(0, 0); this.isAcceleration = false; this.isAccelerated$.next(this.isAcceleration); @@ -1128,41 +1126,26 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { toggleCpfp() { this.cpfpMode = !this.cpfpMode; - if (this.cpfpInfo?.cluster) { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { cpfp: this.cpfpMode ? 'true' : null }, - queryParamsHandling: 'merge', - fragment: this.getCpfpFragment(), - replaceUrl: true, - }); - } else { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { cpfp: this.cpfpMode ? 'simple' : null }, - queryParamsHandling: 'merge', - preserveFragment: true, - replaceUrl: true, - }); - } - } - - private isCpfpParamEnabled(cpfpParam: string | undefined): boolean { - return cpfpParam === 'true' || cpfpParam === 'advanced' || cpfpParam === 'simple'; + this.router.navigate([], { + relativeTo: this.route, + queryParams: { cpfp: this.cpfpMode ? 'true' : null }, + queryParamsHandling: 'merge', + fragment: this.formatFragment(this.fragmentParams), + replaceUrl: true, + }); } - private getCpfpFragment(): string | null { - const currentParams = new URLSearchParams(this.fragmentParams?.toString() || this.route.snapshot.fragment || ''); - const fragmentParams = new URLSearchParams(); - if (this.cpfpMode) { - fragmentParams.set('cluster', ''); - } - for (const [key, value] of currentParams.entries()) { - if (key !== 'cluster') { - fragmentParams.set(key, value); + private formatFragment(fragmentParams: URLSearchParams, anchor: string | null = null): string | null { + const params = new URLSearchParams(fragmentParams.toString()); + for (const [key, value] of Array.from(params.entries())) { + if (value === '') { + params.delete(key); } } - return fragmentParams.toString() || null; + if (anchor) { + params.set(anchor, ''); + } + return params.toString() || null; } toggleGraph() { @@ -1172,7 +1155,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { relativeTo: this.route, queryParams: { showFlow: showFlow }, queryParamsHandling: 'merge', - fragment: 'flow' + fragment: this.formatFragment(this.fragmentParams, showFlow ? 'flow' : null) }); } @@ -1192,11 +1175,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { // simulate normal anchor fragment behavior applyFragment(): void { - const anchor = Array.from(this.fragmentParams.entries()).find(([frag, value]) => value === ''); - if (anchor?.length) { - const anchorElement = document.getElementById(anchor[0]); + if (this.fragmentAnchor && this.scrolledFragmentAnchor !== this.fragmentAnchor) { + const anchorElement = document.getElementById(this.fragmentAnchor); if (anchorElement) { - anchorElement.scrollIntoView({ behavior: 'smooth' }); + anchorElement.scrollIntoView({ behavior: this.firstFragmentScroll ? 'auto' : 'smooth' }); + this.firstFragmentScroll = false; + this.scrolledFragmentAnchor = this.fragmentAnchor; } } } @@ -1205,12 +1189,35 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.fragmentParams = new URLSearchParams(fragment || ''); const vin = parseInt(this.fragmentParams.get('vin'), 10); const vout = parseInt(this.fragmentParams.get('vout'), 10); - this.inputIndex = (!isNaN(vin) && vin >= 0) ? vin : null; - this.outputIndex = (!isNaN(vout) && vout >= 0) ? vout : null; + const inputIndex = (!isNaN(vin) && vin >= 0) ? vin : null; + const outputIndex = (!isNaN(vout) && vout >= 0) ? vout : null; + const selectionChanged = inputIndex !== this.inputIndex || outputIndex !== this.outputIndex; + const anchor = Array.from(this.fragmentParams.entries()).find(([, value]) => value === '')?.[0] || null; + this.inputIndex = inputIndex; + this.outputIndex = outputIndex; if (this.fragmentParams.has('accelerate')) { this.forceAccelerationSummary = true; } - setTimeout(() => { this.applyFragment(); }, 0); + if (!anchor && !this.fragmentAnchor) { + this.firstFragmentScroll = false; + } + if (selectionChanged && anchor) { + this.scrolledFragmentAnchor = null; + } + if (anchor !== this.fragmentAnchor) { + this.fragmentAnchor = anchor; + this.scrolledFragmentAnchor = null; + if (!this.fragmentAnchor) { + this.firstFragmentScroll = false; + } + } + if (this.scrolledFragmentAnchor !== this.fragmentAnchor) { + if (this.fragmentAnchor) { + setTimeout(() => { this.applyFragment(); }, 0); + } else { + this.firstFragmentScroll = false; + } + } } setHasAccelerationDetails(hasDetails: boolean): void { @@ -1237,20 +1244,20 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } onAccelerationCompleted(): void { - this.router.navigate([], { fragment: null, queryParamsHandling: 'merge' }); + this.router.navigate([], { fragment: this.formatFragment(this.fragmentParams), queryParamsHandling: 'merge' }); this.accelerationFlowCompleted = true; this.forceAccelerationSummary = false; } closeAccelerator(): void { - this.router.navigate([], { fragment: null, queryParamsHandling: 'merge' }); + this.router.navigate([], { fragment: this.formatFragment(this.fragmentParams), queryParamsHandling: 'merge' }); this.hideAccelerationSummary = true; this.forceAccelerationSummary = false; this.storageService.setValue('hide-accelerator-pref', 'true'); } openAccelerator(): void { - this.router.navigate([], { fragment: 'accelerate', queryParamsHandling: 'merge' }); + this.router.navigate([], { fragment: this.formatFragment(this.fragmentParams, 'accelerate'), queryParamsHandling: 'merge' }); this.accelerationFlowCompleted = false; this.hideAccelerationSummary = false; this.storageService.setValue('hide-accelerator-pref', 'false');