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 53cd9861b6..59dbe36de4 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html +++ b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html @@ -1,5 +1,5 @@ -
- +
+ - - + + + + + + + - + + - - {{ outline.feerate | feeRounding }} sat/vB - + + {{ outline.feerate | feeRounding }} sat/vB + + - - - - + + + + + + - - - - {{ node.feerate | feeRounding }} - - + + + + + {{ node.feerate | feeRounding }} + + + +
+ +mempool chunk -
-

{{ hoverNode.tx.txid | shortenString }}

-

{{ hoverNode.tx.fee | number }} sat

-

-

{{ hoverNode.feerate | feeRounding }} sat/vB

+
+ +
+ {{ 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 eb55be138b..603a5f9995 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss +++ b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss @@ -1,3 +1,11 @@ +:host { + position: relative; + display: block; + --cluster-ancestor-color: var(--info); + --cluster-descendant-color: var(--primary); + --cluster-direct-color: white; +} + .graph-container { position: relative; width: 100%; @@ -9,6 +17,39 @@ svg { display: inline-block; } + + &.preview { + padding: 0; + overflow: hidden; + pointer-events: none; + border-radius: 4px; + background-color: transparent; + box-shadow: var(--cluster-preview-shadow, none); + cursor: var(--cluster-preview-cursor, default); + transition: box-shadow 150ms ease; + + svg { + display: block; + width: 100%; + -webkit-mask: + linear-gradient(to right, transparent 0, #000 6%, #000 94%, transparent 100%), + linear-gradient(to bottom, transparent 0, #000 12%, #000 88%, transparent 100%); + -webkit-mask-composite: source-in; + mask: + linear-gradient(to right, transparent 0, #000 6%, #000 94%, transparent 100%), + linear-gradient(to bottom, transparent 0, #000 12%, #000 88%, transparent 100%); + mask-composite: intersect; + } + + .node-rect { + stroke-width: 2; + } + + .edge-line { + stroke-width: 0.5; + opacity: 0.55; + } + } } .chunk-border { @@ -40,6 +81,30 @@ stroke-width: 2; transition: stroke 150ms; pointer-events: none; + + &.ancestor { + stroke: var(--cluster-ancestor-color); + } + + &.descendant { + stroke: var(--cluster-descendant-color); + } + + &.direct { + stroke: var(--cluster-direct-color); + } +} + +.edge-arrow-ancestor-path { + fill: var(--cluster-ancestor-color); +} + +.edge-arrow-descendant-path { + fill: var(--cluster-descendant-color); +} + +.edge-arrow-direct-path { + fill: var(--cluster-direct-color); } .node-group { @@ -56,15 +121,19 @@ transition: stroke 150ms; &.current { - stroke: var(--mainnet-alt); + stroke: var(--cluster-direct-color); + } + + &.ancestor { + stroke: var(--cluster-ancestor-color); } - &.related { - stroke: rgba(255, 255, 255, 0.6); + &.descendant { + stroke: var(--cluster-descendant-color); } &.hovered { - stroke: white; + stroke: var(--cluster-direct-color); } } @@ -81,13 +150,78 @@ border-radius: 4px; box-shadow: 1px 1px 10px rgba(0, 0, 0, 0.5); color: var(--tooltip-grey); - padding: 10px 15px; + padding: 8px 12px; text-align: left; pointer-events: none; - max-width: 350px; + max-width: 360px; + white-space: nowrap; + + .tx-id-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .this-tx-badge { + font-size: 0.75em; + background: rgba(255, 255, 255, 0.12); + color: white; + border-radius: 3px; + padding: 1px 6px; + } + + .tooltip-body { + display: flex; + align-items: center; + gap: 14px; + } + + table.stats { + border-collapse: collapse; + + th, td { + padding: 1px 0; + font-weight: normal; + vertical-align: baseline; + } + + th { + text-align: left; + padding-right: 10px; + opacity: 0.7; + } + + .unit { + opacity: 0.7; + font-size: 0.85em; + } + } + + .legend { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.85em; + padding-left: 10px; + border-left: 1px solid rgba(255, 255, 255, 0.1); + + > div { + display: flex; + align-items: center; + gap: 5px; + } + } + + .swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; - p { - margin: 0; - white-space: nowrap; + &.ancestor { background: var(--cluster-ancestor-color); } + &.descendant { background: var(--cluster-descendant-color); } } } 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 59e327285c..df14efcba8 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts +++ b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts @@ -1,15 +1,16 @@ -import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ElementRef, ViewChild, ChangeDetectorRef, HostListener } from '@angular/core'; +import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ElementRef, ViewChild, ChangeDetectorRef, HostListener, AfterViewInit, OnDestroy } from '@angular/core'; import { Router } from '@angular/router'; import { CpfpClusterTx, CpfpClusterChunk } from '@app/interfaces/node-api.interface'; import { ThemeService } from '@app/services/theme.service'; import { StateService } from '@app/services/state.service'; import { feeLevels } from '@app/app.constants'; import { computeGridLayout, GridLayout } from './cluster-layout'; -import { renderLayout, RenderedNode, RenderedEdge, RenderedChunkOutline, NODE_W, NODE_H } from './cluster-renderer'; +import { renderLayout, RenderedNode, RenderedEdge, RenderedChunkOutline } from './cluster-renderer'; -const NODE_RX = 6; const RESIZE_DEBOUNCE_MS = 100; +let nextClusterDiagramInstance = 0; + @Component({ selector: 'app-cluster-diagram', templateUrl: './cluster-diagram.component.html', @@ -17,9 +18,10 @@ const RESIZE_DEBOUNCE_MS = 100; standalone: false, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ClusterDiagramComponent implements OnChanges { +export class ClusterDiagramComponent implements OnChanges, AfterViewInit, OnDestroy { @Input() cluster: { txs: CpfpClusterTx[]; chunks: CpfpClusterChunk[]; chunkIndex: number }; @Input() txid: string; + @Input() preview = false; @ViewChild('graphContainer', { static: true }) graphContainer: ElementRef; @ViewChild('tooltip') tooltipElement: ElementRef; @@ -32,16 +34,19 @@ export class ClusterDiagramComponent implements OnChanges { chunkOutlines: RenderedChunkOutline[] = []; svgWidth = 0; svgHeight = 0; + svgViewBox: string | null = null; activeChunkIndex = 0; hoverNode: RenderedNode | null = null; + hoverEdge: RenderedEdge | null = null; + hoverChunkIndex: number | null = null; tooltipPosition = { x: 0, y: 0 }; - readonly nodeW = NODE_W; - readonly nodeH = NODE_H; - readonly nodeRx = NODE_RX; + readonly idPrefix = `cluster-${++nextClusterDiagramInstance}`; private resizeTimer: ReturnType | null = null; + private resizeObserver: ResizeObserver | null = null; + private lastObservedWidth = 0; constructor( private router: Router, @@ -52,8 +57,7 @@ export class ClusterDiagramComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { if (!this.cluster?.txs?.length) { return; } - this.activeChunkIndex = this.cluster.chunkIndex; - const clusterChanged = changes['cluster']; + const clusterChanged = changes['cluster'] || changes['preview']; if (clusterChanged) { this.computeLayout(); } @@ -61,8 +65,28 @@ export class ClusterDiagramComponent implements OnChanges { } private computeLayout(): void { - this.txs = this.cluster.txs; - this.chunks = this.cluster.chunks; + if (this.preview && this.cluster.chunks.length > 0) { + const activeChunk = this.cluster.chunks[this.cluster.chunkIndex]; + const memberSet = new Set(activeChunk.txs); + const remap = new Map(); + activeChunk.txs.forEach((origIdx, newIdx) => remap.set(origIdx, newIdx)); + + this.txs = activeChunk.txs.map(origIdx => ({ + ...this.cluster.txs[origIdx], + parents: this.cluster.txs[origIdx].parents + .filter(p => memberSet.has(p)) + .map(p => remap.get(p) as number), + })); + this.chunks = [{ + txs: this.txs.map((_, i) => i), + feerate: activeChunk.feerate, + }]; + this.activeChunkIndex = 0; + } else { + this.txs = this.cluster.txs; + this.chunks = this.cluster.chunks; + this.activeChunkIndex = this.cluster.chunkIndex; + } this.gridLayout = computeGridLayout(this.txs, this.chunks); } @@ -88,6 +112,8 @@ export class ClusterDiagramComponent implements OnChanges { txids: this.txs.map(tx => tx.txid), chunkFeerates: this.chunks.map(c => c.feerate), getColor, + preview: this.preview, + idPrefix: this.idPrefix, }); this.nodes = result.nodes; @@ -95,19 +121,23 @@ export class ClusterDiagramComponent implements OnChanges { this.chunkOutlines = result.chunkOutlines; this.svgWidth = result.svgWidth; this.svgHeight = result.svgHeight; + this.svgViewBox = result.viewBox; } onNodeEnter(node: RenderedNode, event: MouseEvent): void { + if (this.preview) { return; } this.hoverNode = node; this.clearHighlights(); node.hovered = true; for (const edge of this.edges) { if (edge.parentIndex === node.index) { - this.nodes[edge.childIndex].related = true; + this.nodes[edge.childIndex].relation = 'descendant'; edge.highlighted = true; + edge.highlightKind = 'descendant'; } else if (edge.childIndex === node.index) { - this.nodes[edge.parentIndex].related = true; + this.nodes[edge.parentIndex].relation = 'ancestor'; edge.highlighted = true; + edge.highlightKind = 'ancestor'; } } this.updateTooltipPosition(event); @@ -115,31 +145,75 @@ export class ClusterDiagramComponent implements OnChanges { } onNodeMove(event: MouseEvent): void { + if (this.preview) { return; } this.updateTooltipPosition(event); this.cd.markForCheck(); } onNodeLeave(): void { + if (this.preview) { return; } this.hoverNode = null; this.clearHighlights(); this.cd.markForCheck(); } - onEdgeEnter(edgeIndex: number): void { + onEdgeEnter(edgeIndex: number, event: MouseEvent): void { + if (this.preview) { return; } this.clearHighlights(); const edge = this.edges[edgeIndex]; edge.highlighted = true; - this.nodes[edge.parentIndex].related = true; - this.nodes[edge.childIndex].related = 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; } + this.updateTooltipPosition(event); this.cd.markForCheck(); } onEdgeLeave(): void { + if (this.preview) { return; } + this.hoverEdge = null; this.clearHighlights(); this.cd.markForCheck(); } + onChunkEnter(chunkIndex: number): void { + if (this.preview) { return; } + this.hoverChunkIndex = chunkIndex; + this.applyEffectiveChunk(); + this.cd.markForCheck(); + } + + onChunkLeave(): void { + if (this.preview) { return; } + this.hoverChunkIndex = null; + this.applyEffectiveChunk(); + this.cd.markForCheck(); + } + + get effectiveActiveChunk(): number { + return this.hoverChunkIndex ?? this.activeChunkIndex; + } + + private applyEffectiveChunk(): void { + const effective = this.effectiveActiveChunk; + for (const node of this.nodes) { + node.inactive = node.chunkIndex !== effective; + } + for (const edge of this.edges) { + edge.parentInactive = this.nodes[edge.parentIndex].chunkIndex !== effective; + edge.childInactive = this.nodes[edge.childIndex].chunkIndex !== effective; + } + } + onNodeClick(node: RenderedNode): void { + if (this.preview) { return; } const network = this.stateService.network; const prefix = network && network !== 'mainnet' ? `/${network}` : ''; this.router.navigate([prefix + '/tx/', node.tx.txid]); @@ -148,41 +222,34 @@ export class ClusterDiagramComponent implements OnChanges { private clearHighlights(): void { for (const node of this.nodes) { node.hovered = false; - node.related = false; + node.relation = null; } for (const edge of this.edges) { edge.highlighted = false; + edge.highlightKind = null; } } private updateTooltipPosition(event: MouseEvent): void { if (!this.graphContainer) { return; } + if (!this.hoverNode && !this.hoverEdge) { return; } const container = this.graphContainer.nativeElement; const rect = container.getBoundingClientRect(); - let x = event.clientX - rect.left + container.scrollLeft + 15; - let y = event.clientY - rect.top + container.scrollTop + 15; - + const pointerX = event.clientX - rect.left; + const pad = 0; + const visibleW = rect.width; + let tipW = Math.min(360, visibleW); if (this.tooltipElement) { - const tipRect = this.tooltipElement.nativeElement.getBoundingClientRect(); - const visibleLeft = container.scrollLeft; - const visibleRight = visibleLeft + rect.width; - const visibleTop = container.scrollTop; - const visibleBottom = visibleTop + rect.height; - - if (x + tipRect.width > visibleRight) { - x = Math.max(visibleLeft, visibleRight - tipRect.width - 10); - } - if (x < visibleLeft) { - x = visibleLeft; - } - if (y + tipRect.height > visibleBottom) { - y = y - tipRect.height - 30; - } - if (y < visibleTop) { - y = visibleTop; + const measuredW = this.tooltipElement.nativeElement.getBoundingClientRect().width; + if (measuredW > 0) { + tipW = Math.min(measuredW, visibleW); } } + const onLeftHalf = pointerX < visibleW / 2; + const x = onLeftHalf ? visibleW - tipW - pad : pad; + const y = pad; + this.tooltipPosition = { x, y }; } @@ -200,6 +267,31 @@ export class ClusterDiagramComponent implements OnChanges { @HostListener('window:resize') onResize(): void { + this.scheduleRerender(); + } + + ngAfterViewInit(): void { + if (typeof ResizeObserver !== 'undefined' && this.graphContainer?.nativeElement) { + this.resizeObserver = new ResizeObserver(entries => { + const width = entries[0]?.contentRect.width ?? 0; + if (Math.abs(width - this.lastObservedWidth) < 1) { return; } + this.lastObservedWidth = width; + this.scheduleRerender(); + }); + this.resizeObserver.observe(this.graphContainer.nativeElement); + } + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + if (this.resizeTimer !== null) { + clearTimeout(this.resizeTimer); + this.resizeTimer = null; + } + } + + private scheduleRerender(): void { if (this.resizeTimer !== null) { clearTimeout(this.resizeTimer); } diff --git a/frontend/src/app/components/cluster-diagram/cluster-layout.ts b/frontend/src/app/components/cluster-diagram/cluster-layout.ts index c9d0e2c389..ffc5ea9963 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-layout.ts +++ b/frontend/src/app/components/cluster-diagram/cluster-layout.ts @@ -140,6 +140,18 @@ function assignRows( ): Int32Array { const rows = new Int32Array(n); const placed = new Uint8Array(n); + const nodeCol = new Int32Array(n); + for (let c = 0; c < colNodes.length; c++) { + for (const i of colNodes[c]) { nodeCol[i] = c; } + } + + const nearestDescendantCol = new Int32Array(n); + nearestDescendantCol.fill(maxCol + 1); + for (let i = n - 1; i >= 0; i--) { + for (const ch of children[i]) { + nearestDescendantCol[i] = Math.min(nearestDescendantCol[i], nodeCol[ch], nearestDescendantCol[ch]); + } + } for (let c = maxCol; c >= 0; c--) { const layer = colNodes[c]; @@ -160,14 +172,24 @@ function assignRows( return (totalRows - 1) / 2; }); - const sorted = layer.map((nodeIdx, arrIdx) => ({ nodeIdx, arrIdx, target: targets[arrIdx], chunk: txChunk[nodeIdx] })); + const sorted = layer.map((nodeIdx, arrIdx) => ({ + nodeIdx, arrIdx, target: targets[arrIdx], chunk: txChunk[nodeIdx], nearestDescendantCol: nearestDescendantCol[nodeIdx], + })); sorted.sort((a, b) => { const dt = a.target - b.target; if (Math.abs(dt) > 0.001) { return dt; } - return a.chunk - b.chunk; + if (a.chunk !== b.chunk) { return a.chunk - b.chunk; } + if (a.nearestDescendantCol !== b.nearestDescendantCol) { + return a.nearestDescendantCol - b.nearestDescendantCol; + } + return a.nodeIdx - b.nodeIdx; }); - const slots = pickSlots(sorted.map(s => s.target), totalRows); + const slots = pickSlots( + sorted.map(s => s.target), + totalRows, + hasDescendantColumnTie(sorted.map(s => s.nodeIdx), sorted.map(s => s.target), nearestDescendantCol, maxCol), + ); for (let k = 0; k < sorted.length; k++) { rows[sorted[k].nodeIdx] = slots[k]; placed[sorted[k].nodeIdx] = 1; @@ -178,7 +200,7 @@ function assignRows( const forward = pass % 2 === 0; for (let c = forward ? 0 : maxCol; forward ? c <= maxCol : c >= 0; forward ? c++ : c--) { const layer = colNodes[c]; - if (layer.length <= 1) { continue; } + if (layer.length === 0) { continue; } const targets = layer.map(i => { const neighbors = forward ? txs[i].parents : children[i]; @@ -189,11 +211,22 @@ function assignRows( }); const indexed = layer.map((_, idx) => idx); - indexed.sort((a, b) => targets[a] - targets[b]); + indexed.sort((a, b) => { + const dt = targets[a] - targets[b]; + if (Math.abs(dt) > 0.001) { return dt; } + if (nearestDescendantCol[layer[a]] !== nearestDescendantCol[layer[b]]) { + return nearestDescendantCol[layer[a]] - nearestDescendantCol[layer[b]]; + } + return layer[a] - layer[b]; + }); - const currentSlots = layer.map(i => rows[i]).sort((a, b) => a - b); + const slots = pickSlots( + indexed.map(i => targets[i]), + totalRows, + hasDescendantColumnTie(indexed.map(i => layer[i]), indexed.map(i => targets[i]), nearestDescendantCol, maxCol), + ); for (let k = 0; k < indexed.length; k++) { - rows[layer[indexed[k]]] = currentSlots[k]; + rows[layer[indexed[k]]] = slots[k]; } } } @@ -203,9 +236,30 @@ function assignRows( return rows; } -function pickSlots(targets: number[], totalRows: number): number[] { +function hasDescendantColumnTie( + nodes: number[], + targets: number[], + nearestDescendantCol: Int32Array, + maxCol: number, +): boolean { + for (let i = 0; i < nodes.length; i++) { + if (nearestDescendantCol[nodes[i]] > maxCol) { continue; } + for (let j = 0; j < nodes.length; j++) { + if ( + i !== j + && nearestDescendantCol[nodes[i]] !== nearestDescendantCol[nodes[j]] + && Math.abs(targets[i] - targets[j]) < 0.001 + ) { + return true; + } + } + } + return false; +} + +function pickSlots(targets: number[], totalRows: number, packFullLayer = false): number[] { const count = targets.length; - if (count >= totalRows) { + if (count >= totalRows && !packFullLayer) { return targets.map((_, i) => i); } @@ -278,25 +332,50 @@ function computeWaypoints( return waypoints; } + const lo = -1; + const hi = maxRow + 1; for (let c = pNode.col + 1; c < cNode.col; c++) { const frac = (c - pNode.col) / (cNode.col - pNode.col); const idealRow = pNode.row + frac * (cNode.row - pNode.row); - let bestRow = Math.max(0, Math.min(maxRow, Math.round(idealRow))); - - if (nodeAt.has(cellKey(c, bestRow))) { - for (let dr = 1; dr <= maxRow; dr++) { - if (bestRow + dr <= maxRow && !nodeAt.has(cellKey(c, bestRow + dr))) { bestRow = bestRow + dr; break; } - if (bestRow - dr >= 0 && !nodeAt.has(cellKey(c, bestRow - dr))) { bestRow = bestRow - dr; break; } - } - } + const targetRow = Math.max(lo, Math.min(hi, Math.round(idealRow))); - waypoints.push({ col: c, row: bestRow }); + waypoints.push({ + col: c, + row: selectOpenWaypointRow(c, targetRow, idealRow, nodeAt, lo, hi), + }); } waypoints.push({ col: cNode.col, row: cNode.row }); return waypoints; } +function selectOpenWaypointRow( + col: number, + targetRow: number, + idealRow: number, + nodeAt: Map, + lo: number, + hi: number, +): number { + if (!nodeAt.has(cellKey(col, targetRow))) { + return targetRow; + } + + const preferDown = idealRow > targetRow; + for (let dr = 1; dr <= hi - lo; dr++) { + const first = preferDown ? targetRow + dr : targetRow - dr; + const second = preferDown ? targetRow - dr : targetRow + dr; + if (first >= lo && first <= hi && !nodeAt.has(cellKey(col, first))) { + return first; + } + if (second >= lo && second <= hi && !nodeAt.has(cellKey(col, second))) { + return second; + } + } + + return targetRow; +} + interface VSeg { edgeIdx: number; segIdx: number; @@ -775,7 +854,11 @@ function assignHorizontalLanes(edges: GridEdge[], rowLaneCounts: number[]): void edgeSegments.sort((a, b) => a.col - b.col); let start = 0; for (let i = 1; i <= edgeSegments.length; i++) { - if (i === edgeSegments.length || edgeSegments[i].col !== edgeSegments[i - 1].col + 1) { + const prev = edgeSegments[i - 1]; + const curr = edgeSegments[i]; + const contiguous = curr && curr.col === prev.col + 1 + && !(prev.type === 'approach' && curr.type === 'approach'); + if (i === edgeSegments.length || !contiguous) { const spanSegs = edgeSegments.slice(start, i); const colOrders = new Map(); for (const s of spanSegs) { if (s.type !== 'approach') { colOrders.set(s.col, s.order); } } @@ -907,9 +990,34 @@ function assignLanes( for (let i = 0; i < n; i++) { lane[i] = Math.floor((minLane[i] + totalLanes - 1 - distToSink[i]) / 2); } + separateOverlappingLanes(lines, lane, compare); return lane; } +function separateOverlappingLanes( + lines: { minPos: number; maxPos: number }[], + lane: number[], + compare: (i: number, j: number) => number, +): void { + for (let pass = 0; pass < lines.length * lines.length; pass++) { + let changed = false; + + for (let i = 0; i < lines.length; i++) { + for (let j = i + 1; j < lines.length; j++) { + const lo = Math.max(lines[i].minPos, lines[j].minPos); + const hi = Math.min(lines[i].maxPos, lines[j].maxPos); + if (lo >= hi || lane[i] !== lane[j]) { continue; } + + const cmp = compare(i, j); + lane[cmp > 0 ? i : j]++; + changed = true; + } + } + + if (!changed) { return; } + } +} + function clusterOverlapping(items: T[]): T[][] { const clusters: T[][] = []; @@ -1049,19 +1157,17 @@ function computeAlternativeWaypoints( ): { col: number; row: number }[] { const waypoints: { col: number; row: number }[] = [{ col: pNode.col, row: pNode.row }]; + const lo = -1; + const hi = maxRow + 1; for (let c = pNode.col + 1; c < cNode.col; c++) { const frac = (c - pNode.col) / (cNode.col - pNode.col); const idealRow = pNode.row + frac * (cNode.row - pNode.row); - let targetRow = Math.max(0, Math.min(maxRow, Math.round(idealRow) + offset)); + const targetRow = Math.max(lo, Math.min(hi, Math.round(idealRow) + offset)); - if (nodeAt.has(cellKey(c, targetRow))) { - for (let dr = 1; dr <= maxRow; dr++) { - if (targetRow + dr <= maxRow && !nodeAt.has(cellKey(c, targetRow + dr))) { targetRow = targetRow + dr; break; } - if (targetRow - dr >= 0 && !nodeAt.has(cellKey(c, targetRow - dr))) { targetRow = targetRow - dr; break; } - } - } - - waypoints.push({ col: c, row: targetRow }); + waypoints.push({ + col: c, + row: selectOpenWaypointRow(c, targetRow, idealRow, nodeAt, lo, hi), + }); } waypoints.push({ col: cNode.col, row: cNode.row }); diff --git a/frontend/src/app/components/cluster-diagram/cluster-renderer.ts b/frontend/src/app/components/cluster-diagram/cluster-renderer.ts index fdd5090439..69f30d580d 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-renderer.ts +++ b/frontend/src/app/components/cluster-diagram/cluster-renderer.ts @@ -1,5 +1,7 @@ import { GridLayout, GridNode, GridEdge, ChunkRegion, cellKey, cellCol, cellRow } from './cluster-layout'; +export type RelatedKind = null | 'ancestor' | 'descendant' | 'direct'; + export interface RenderedNode { index: number; tx: { txid: string; fee: number; weight: number }; @@ -7,13 +9,17 @@ export interface RenderedNode { y: number; rectX: number; rectY: number; + width: number; + height: number; + rx: number; color: string; chunkIndex: number; feerate: number; inactive: boolean; isCurrent: boolean; hovered: boolean; - related: boolean; + relation: RelatedKind; + visible: boolean; } export interface RenderedEdge { @@ -31,6 +37,8 @@ export interface RenderedEdge { x2: number; y2: number; highlighted: boolean; + highlightKind: RelatedKind; + visible: boolean; } export interface RenderedChunkOutline { @@ -50,6 +58,8 @@ export interface RenderParams { txids: string[]; chunkFeerates: number[]; getColor: (feerate: number) => string; + preview?: boolean; + idPrefix: string; } interface RenderResult { @@ -58,6 +68,19 @@ interface RenderResult { chunkOutlines: RenderedChunkOutline[]; svgWidth: number; svgHeight: number; + viewBox: string | null; +} + +interface RenderDimensions { + nodeW: number; + nodeH: number; + cellPadY: number; + minCellW: number; + maxCellW: number; + marginX: number; + marginY: number; + laneSpacing: number; + nodeRx: number; } interface GridGeometry { @@ -68,55 +91,100 @@ interface GridGeometry { cellW: number; totalCols: number; totalRows: number; -} - -export const NODE_W = 64; -export const NODE_H = 30; -const MIN_CELL_W = 80; -const MAX_CELL_W = 120; -const CELL_PAD_Y = 16; -const MARGIN_X = 20; -const MARGIN_Y = 30; -const LANE_SPACING = 6; -const MIN_GUTTER_W = 3 * LANE_SPACING; -const MIN_GUTTER_H = 3 * LANE_SPACING; + virtualTopY: number; + virtualBottomY: number; + dim: RenderDimensions; +} + +const DEFAULT_DIMENSIONS: RenderDimensions = { + nodeW: 64, + nodeH: 30, + cellPadY: 16, + minCellW: 80, + maxCellW: 120, + marginX: 20, + marginY: 30, + laneSpacing: 6, + nodeRx: 6, +}; + +const PREVIEW_DIMENSIONS: RenderDimensions = { + nodeW: 10, + nodeH: 10, + cellPadY: 2, + minCellW: 18, + maxCellW: 80, + marginX: 3, + marginY: 3, + laneSpacing: 2, + nodeRx: 2, +}; + +const PREVIEW_VIEWPORT_HEIGHT = 48; const OUTLINE_PAD = 6; export function renderLayout(layout: GridLayout, params: RenderParams): RenderResult { if (layout.nodes.length === 0) { - return { nodes: [], edges: [], chunkOutlines: [], svgWidth: 0, svgHeight: 0 }; + return { nodes: [], edges: [], chunkOutlines: [], svgWidth: 0, svgHeight: 0, viewBox: null }; } - const cellH = NODE_H + CELL_PAD_Y; + const dim = params.preview ? PREVIEW_DIMENSIONS : DEFAULT_DIMENSIONS; + const minGutterW = 3 * dim.laneSpacing; + const minGutterH = 3 * dim.laneSpacing; + const cellH = dim.nodeH + dim.cellPadY; const rowHeights: number[] = []; for (let r = 0; r < layout.rows; r++) { const laneCount = (layout.rowLaneCounts && layout.rowLaneCounts[r]) || 0; if (r % 2 === 0) { - rowHeights.push(Math.max(cellH, (laneCount + 1) * LANE_SPACING)); + rowHeights.push(Math.max(cellH, (laneCount + 1) * dim.laneSpacing)); } else { - rowHeights.push(Math.max(MIN_GUTTER_H, (laneCount + 1) * LANE_SPACING)); + rowHeights.push(Math.max(minGutterH, (laneCount + 1) * dim.laneSpacing)); } } - const rowTopY: number[] = [MARGIN_Y]; + let needsVirtualTop = false; + let needsVirtualBottom = false; + for (const e of layout.edges) { + for (const wp of e.waypoints) { + if (wp.row < 0) { needsVirtualTop = true; } + if (wp.row >= layout.rows) { needsVirtualBottom = true; } + } + } + const virtualGap = dim.laneSpacing * 3; + const topPad = needsVirtualTop ? virtualGap : 0; + const bottomPad = needsVirtualBottom ? virtualGap : 0; + + const rowTopY: number[] = [dim.marginY + topPad]; for (let r = 1; r < layout.rows; r++) { rowTopY.push(rowTopY[r - 1] + rowHeights[r - 1]); } + const virtualTopY = needsVirtualTop ? dim.marginY + topPad / 2 : dim.marginY; + const virtualBottomY = layout.rows > 0 + ? rowTopY[layout.rows - 1] + rowHeights[layout.rows - 1] + bottomPad / 2 + : dim.marginY; const gutterWidths: number[] = []; for (let c = 0; c < layout.cols - 1; c++) { const laneCount = (layout.colLaneCounts && layout.colLaneCounts[c]) || 0; - gutterWidths.push(Math.max(MIN_GUTTER_W, (laneCount + 1) * LANE_SPACING)); + gutterWidths.push(Math.max(minGutterW, (laneCount + 1) * dim.laneSpacing)); } const totalGutterW = gutterWidths.reduce((a, b) => a + b, 0); - const fixedWidth = MARGIN_X * 2 + totalGutterW; - const cellW = layout.cols > 0 - ? Math.max(MIN_CELL_W, Math.min(MAX_CELL_W, (params.containerWidth - fixedWidth) / layout.cols)) - : MIN_CELL_W; + const fixedWidth = dim.marginX * 2 + totalGutterW; + let cellW: number; + if (params.preview) { + const activeCols = activeChunkColCount(layout, params.activeChunkIndex); + const denom = Math.max(1, activeCols); + cellW = Math.max(dim.minCellW, Math.min(dim.maxCellW, + (params.containerWidth - dim.marginX * 2) / denom)); + } else if (layout.cols > 0) { + cellW = Math.max(dim.minCellW, Math.min(dim.maxCellW, (params.containerWidth - fixedWidth) / layout.cols)); + } else { + cellW = dim.minCellW; + } - const colLeftX: number[] = [MARGIN_X]; + const colLeftX: number[] = [dim.marginX]; for (let c = 1; c < layout.cols; c++) { colLeftX.push(colLeftX[c - 1] + cellW + gutterWidths[c - 1]); } @@ -124,16 +192,67 @@ export function renderLayout(layout: GridLayout, params: RenderParams): RenderRe const geo: GridGeometry = { colLeftX, rowTopY, rowHeights, gutterWidths, cellW, totalCols: layout.cols, totalRows: layout.rows, + virtualTopY, virtualBottomY, + dim, }; const nodes = renderNodes(layout.nodes, params, geo); - const edges = renderEdges(layout.edges, nodes, geo); - const chunkOutlines = renderChunkOutlines(layout.chunks, params, geo); + const edges = renderEdges(layout.edges, nodes, geo, params.idPrefix); + const chunkOutlines = params.preview ? [] : renderChunkOutlines(layout.chunks, params, geo); + + const fullWidth = dim.marginX * 2 + layout.cols * cellW + totalGutterW; + const fullHeight = rowTopY[layout.rows - 1] + rowHeights[layout.rows - 1] + bottomPad + dim.marginY; + + if (params.preview) { + const selected = nodes.find(n => n.isCurrent && n.visible) ?? nodes.find(n => n.visible); + if (!selected) { + return { nodes, edges, chunkOutlines, svgWidth: fullWidth, svgHeight: fullHeight, viewBox: null }; + } - const svgWidth = MARGIN_X * 2 + layout.cols * cellW + totalGutterW; - const svgHeight = rowTopY[layout.rows - 1] + rowHeights[layout.rows - 1] + MARGIN_Y; + const vbHeight = PREVIEW_VIEWPORT_HEIGHT; + const vbWidth = Math.max(dim.minCellW * 3, params.containerWidth || dim.minCellW * 8); + + let activeMinX = Infinity, activeMaxX = -Infinity; + let activeMinY = Infinity, activeMaxY = -Infinity; + for (const n of nodes) { + if (n.visible) { + activeMinX = Math.min(activeMinX, n.rectX); + activeMaxX = Math.max(activeMaxX, n.rectX + n.width); + activeMinY = Math.min(activeMinY, n.rectY); + activeMaxY = Math.max(activeMaxY, n.rectY + n.height); + } + } + const pad = dim.marginX; + const chunkFits = isFinite(activeMinX) && (activeMaxX - activeMinX) + 2 * pad <= vbWidth; + const vbX = chunkFits + ? (activeMinX + activeMaxX) / 2 - vbWidth / 2 + : selected.x - vbWidth / 2; + const chunkFitsVertically = isFinite(activeMinY) && (activeMaxY - activeMinY) + 2 * dim.marginY <= vbHeight; + const vbY = chunkFitsVertically + ? (activeMinY + activeMaxY) / 2 - vbHeight / 2 + : selected.y - vbHeight / 2; - return { nodes, edges, chunkOutlines, svgWidth, svgHeight }; + return { + nodes, edges, chunkOutlines, + svgWidth: vbWidth, + svgHeight: vbHeight, + viewBox: `${vbX} ${vbY} ${vbWidth} ${vbHeight}`, + }; + } + + return { nodes, edges, chunkOutlines, svgWidth: fullWidth, svgHeight: fullHeight, viewBox: null }; +} + +function activeChunkColCount(layout: GridLayout, activeChunkIndex: number): number { + let minCol = Infinity, maxCol = -Infinity; + for (const n of layout.nodes) { + if (n.chunkIndex === activeChunkIndex) { + if (n.col < minCol) { minCol = n.col; } + if (n.col > maxCol) { maxCol = n.col; } + } + } + if (!isFinite(minCol)) { return layout.cols; } + return maxCol - minCol + 1; } function geoCellX(geo: GridGeometry, col: number): number { @@ -141,7 +260,8 @@ function geoCellX(geo: GridGeometry, col: number): number { } function geoCellY(geo: GridGeometry, row: number): number { - if (row < 0 || row >= geo.totalRows) { return MARGIN_Y; } + if (row < 0) { return geo.virtualTopY; } + if (row >= geo.totalRows) { return geo.virtualBottomY; } return geo.rowTopY[row] + geo.rowHeights[row] / 2; } @@ -154,7 +274,8 @@ function geoCellLeft(geo: GridGeometry, col: number): number { } function geoCellTop(geo: GridGeometry, row: number): number { - if (row < 0 || row >= geo.totalRows) { return MARGIN_Y; } + if (row < 0) { return geo.virtualTopY; } + if (row >= geo.totalRows) { return geo.virtualBottomY; } return geo.rowTopY[row]; } @@ -163,19 +284,24 @@ function geoCellRight(geo: GridGeometry, col: number): number { } function geoCellBottom(geo: GridGeometry, row: number): number { - if (row < 0 || row >= geo.totalRows) { return MARGIN_Y; } + if (row < 0) { return geo.virtualTopY; } + if (row >= geo.totalRows) { return geo.virtualBottomY; } return geo.rowTopY[row] + geo.rowHeights[row]; } -function borderX(geo: GridGeometry, b: number): number { +function borderX(geo: GridGeometry, b: number, side: 'left' | 'right' | null = null): number { if (b <= 0) { return geoCellLeft(geo, 0) - OUTLINE_PAD; } if (b >= geo.totalCols) { return geoCellRight(geo, geo.totalCols - 1) + OUTLINE_PAD; } + if (side === 'left') { return geoCellLeft(geo, b) - geo.dim.laneSpacing / 2; } + if (side === 'right') { return geoCellRight(geo, b - 1) + geo.dim.laneSpacing / 2; } return (geoCellRight(geo, b - 1) + geoCellLeft(geo, b)) / 2; } -function borderY(geo: GridGeometry, b: number): number { +function borderY(geo: GridGeometry, b: number, side: 'top' | 'bottom' | null = null): number { if (b <= 0) { return geoCellTop(geo, 0) - OUTLINE_PAD; } if (b >= geo.totalRows) { return geoCellBottom(geo, geo.totalRows - 1) + OUTLINE_PAD; } + if (side === 'top') { return geoCellTop(geo, b) + geo.dim.laneSpacing / 2; } + if (side === 'bottom') { return geoCellBottom(geo, b - 1) - geo.dim.laneSpacing / 2; } return (geoCellBottom(geo, b - 1) + geoCellTop(geo, b)) / 2; } @@ -186,6 +312,7 @@ function renderNodes(gridNodes: GridNode[], params: RenderParams, geo: GridGeome const feerate = params.txFees[gn.index] / (params.txWeights[gn.index] / 4); const color = params.getColor(feerate); const inactive = gn.chunkIndex !== params.activeChunkIndex; + const visible = !params.preview || !inactive; return { index: gn.index, @@ -195,15 +322,19 @@ function renderNodes(gridNodes: GridNode[], params: RenderParams, geo: GridGeome weight: params.txWeights[gn.index], }, x, y, - rectX: x - NODE_W / 2, - rectY: y - NODE_H / 2, + rectX: x - geo.dim.nodeW / 2, + rectY: y - geo.dim.nodeH / 2, + width: geo.dim.nodeW, + height: geo.dim.nodeH, + rx: geo.dim.nodeRx, color, chunkIndex: gn.chunkIndex, feerate, inactive, isCurrent: params.txids[gn.index] === params.currentTxid, hovered: false, - related: false, + relation: null, + visible, }; }); } @@ -216,8 +347,9 @@ interface FanInfo { function computeFanInfos( gridEdges: GridEdge[], side: 'exit' | 'entry', + dim: RenderDimensions, ): (FanInfo | undefined)[] { - const nodeHalfH = NODE_H / 2; + const nodeHalfH = dim.nodeH / 2; const nodeKey = side === 'exit' ? 'parent' : 'child'; const groups = new Map(); @@ -241,7 +373,7 @@ function computeFanInfos( for (const ei of edgeIndices) { const slot = side === 'exit' ? gridEdges[ei].exitSlot : gridEdges[ei].entrySlot; const count = side === 'exit' ? gridEdges[ei].exitCount : gridEdges[ei].entryCount; - if (Math.abs(slotToOffset(slot, count)) > nodeHalfH) { + if (Math.abs(slotToOffset(slot, count, dim)) > nodeHalfH) { anyNeedsFan = true; break; } @@ -250,14 +382,14 @@ function computeFanInfos( if (!anyNeedsFan) { continue; } const nodeCount = edgeIndices.length; - const edgeSpacing = (NODE_H - 2) / Math.max(1, nodeCount - 1); + const edgeSpacing = (dim.nodeH - 2) / Math.max(1, nodeCount - 1); let maxDy = 0; const nodeEdgeOffsets: number[] = []; for (let k = 0; k < edgeIndices.length; k++) { const slot = side === 'exit' ? gridEdges[edgeIndices[k]].exitSlot : gridEdges[edgeIndices[k]].entrySlot; const count = side === 'exit' ? gridEdges[edgeIndices[k]].exitCount : gridEdges[edgeIndices[k]].entryCount; - const laneOffset = slotToOffset(slot, count); + const laneOffset = slotToOffset(slot, count, dim); const nodeEdgeOffset = (k - (nodeCount - 1) / 2) * edgeSpacing; nodeEdgeOffsets.push(nodeEdgeOffset); maxDy = Math.max(maxDy, Math.abs(laneOffset - nodeEdgeOffset)); @@ -275,9 +407,10 @@ function computeFanInfos( function renderEdges( gridEdges: GridEdge[], renderedNodes: RenderedNode[], geo: GridGeometry, + idPrefix: string, ): RenderedEdge[] { - const exitFanInfos = computeFanInfos(gridEdges, 'exit'); - const entryFanInfos = computeFanInfos(gridEdges, 'entry'); + const exitFanInfos = computeFanInfos(gridEdges, 'exit', geo.dim); + const entryFanInfos = computeFanInfos(gridEdges, 'entry', geo.dim); return gridEdges.map((ge, idx) => { const pNode = renderedNodes[ge.parent]; @@ -293,8 +426,8 @@ function renderEdges( parentIndex: ge.parent, childIndex: ge.child, path, - gradientId: `edge-grad-${idx}`, - markerId: `edge-arrow-${idx}`, + gradientId: `${idPrefix}-edge-grad-${idx}`, + markerId: `${idPrefix}-edge-arrow-${idx}`, parentColor: pNode.color, childColor: cNode.color, parentInactive: pNode.inactive, @@ -302,6 +435,8 @@ function renderEdges( x1: first.x, y1: first.y, x2: last.x, y2: last.y, highlighted: false, + highlightKind: null, + visible: pNode.visible && cNode.visible, }; }); } @@ -319,11 +454,11 @@ function waypointsToPixels( const wp = edge.waypoints; if (wp.length < 2) { return []; } - const exitOffset = slotToOffset(edge.exitSlot, edge.exitCount); - const entryOffset = slotToOffset(edge.entrySlot, edge.entryCount); + const exitOffset = slotToOffset(edge.exitSlot, edge.exitCount, geo.dim); + const entryOffset = slotToOffset(edge.entrySlot, edge.entryCount, geo.dim); const points: PathPoint[] = []; - const startNodeX = geoCellX(geo, wp[0].col) + NODE_W / 2; + const startNodeX = geoCellX(geo, wp[0].col) + geo.dim.nodeW / 2; const cy0 = geoCellY(geo, wp[0].row); if (exitFan) { @@ -336,7 +471,7 @@ function waypointsToPixels( let curY = cy0 + exitOffset; for (let i = 0; i < wp.length - 1; i++) { const vInfo = edge.verticalSlots[i]; - const gx = geoGutterCenterX(geo, wp[i].col) + slotToOffset(vInfo.slot, vInfo.count); + const gx = geoGutterCenterX(geo, wp[i].col) + slotToOffset(vInfo.slot, vInfo.count, geo.dim); const isLast = i === wp.length - 2; let nextY: number; @@ -344,7 +479,7 @@ function waypointsToPixels( nextY = geoCellY(geo, wp[i + 1].row) + entryOffset; } else { const hInfo = edge.horizontalSlots[i]; - nextY = geoCellY(geo, wp[i + 1].row) + slotToOffset(hInfo.slot, hInfo.count); + nextY = geoCellY(geo, wp[i + 1].row) + slotToOffset(hInfo.slot, hInfo.count, geo.dim); } if (points[points.length - 1].x !== gx) { @@ -358,12 +493,12 @@ function waypointsToPixels( if (!isLast) { const nextVInfo = edge.verticalSlots[i + 1]; - const nextGx = geoGutterCenterX(geo, wp[i + 1].col) + slotToOffset(nextVInfo.slot, nextVInfo.count); + const nextGx = geoGutterCenterX(geo, wp[i + 1].col) + slotToOffset(nextVInfo.slot, nextVInfo.count, geo.dim); points.push({ x: nextGx, y: curY }); } } - const endNodeX = geoCellX(geo, wp[wp.length - 1].col) - NODE_W / 2; + const endNodeX = geoCellX(geo, wp[wp.length - 1].col) - geo.dim.nodeW / 2; const cyLast = geoCellY(geo, wp[wp.length - 1].row); if (entryFan) { @@ -376,9 +511,9 @@ function waypointsToPixels( return points; } -function slotToOffset(slot: number, count: number): number { +function slotToOffset(slot: number, count: number, dim: RenderDimensions): number { if (count <= 1) { return 0; } - return (slot - (count - 1) / 2) * LANE_SPACING; + return (slot - (count - 1) / 2) * dim.laneSpacing; } function buildEdgePath(points: PathPoint[]): string { @@ -445,7 +580,7 @@ function renderChunkOutlines( const ty = borderY(geo, r); if (ty < labelY || (ty === labelY && cx < labelX)) { labelX = cx; - labelY = ty - 4; + labelY = ty - 8; } } @@ -469,10 +604,16 @@ function collectBoundarySegments(chunk: ChunkRegion, geo: GridGeometry): Segment for (const cell of chunk.cells) { const c = cellCol(cell), r = cellRow(cell); - const left = borderX(geo, c); - const right = borderX(geo, c + 1); - const top = borderY(geo, r); - const bottom = borderY(geo, r + 1); + const left = cornerX(chunk, geo, c, r); + const right = cornerX(chunk, geo, c + 1, r); + const bottomLeft = cornerX(chunk, geo, c, r + 1); + const bottomRight = cornerX(chunk, geo, c + 1, r + 1); + const top = borderY(geo, r, 'top'); + const bottom = borderY(geo, r + 1, 'bottom'); + const leftTop = cornerY(chunk, geo, c, r); + const leftBottom = cornerY(chunk, geo, c, r + 1); + const rightTop = cornerY(chunk, geo, c + 1, r); + const rightBottom = cornerY(chunk, geo, c + 1, r + 1); const hasAbove = chunk.cells.has(cellKey(c, r - 1)); const hasBelow = chunk.cells.has(cellKey(c, r + 1)); @@ -480,14 +621,54 @@ function collectBoundarySegments(chunk: ChunkRegion, geo: GridGeometry): Segment const hasRight = chunk.cells.has(cellKey(c + 1, r)); if (!hasAbove) { segments.push({ x1: left, y1: top, x2: right, y2: top }); } - if (!hasBelow) { segments.push({ x1: right, y1: bottom, x2: left, y2: bottom }); } - if (!hasLeft) { segments.push({ x1: left, y1: bottom, x2: left, y2: top }); } - if (!hasRight) { segments.push({ x1: right, y1: top, x2: right, y2: bottom }); } + if (!hasBelow) { segments.push({ x1: bottomRight, y1: bottom, x2: bottomLeft, y2: bottom }); } + if (!hasLeft) { + const x = borderX(geo, c, 'left'); + segments.push({ x1: x, y1: leftBottom, x2: x, y2: leftTop }); + } + if (!hasRight) { + const x = borderX(geo, c + 1, 'right'); + segments.push({ x1: x, y1: rightTop, x2: x, y2: rightBottom }); + } } return segments; } +function cornerX(chunk: ChunkRegion, geo: GridGeometry, col: number, row: number): number { + const side = verticalBoundarySide( + chunk.cells.has(cellKey(col - 1, row)), + chunk.cells.has(cellKey(col, row)), + ) || verticalBoundarySide( + chunk.cells.has(cellKey(col - 1, row - 1)), + chunk.cells.has(cellKey(col, row - 1)), + ); + return borderX(geo, col, side); +} + +function verticalBoundarySide(leftOccupied: boolean, rightOccupied: boolean): 'left' | 'right' | null { + if (!leftOccupied && rightOccupied) { return 'left'; } + if (leftOccupied && !rightOccupied) { return 'right'; } + return null; +} + +function cornerY(chunk: ChunkRegion, geo: GridGeometry, col: number, row: number): number { + const side = horizontalBoundarySide( + chunk.cells.has(cellKey(col, row - 1)), + chunk.cells.has(cellKey(col, row)), + ) || horizontalBoundarySide( + chunk.cells.has(cellKey(col - 1, row - 1)), + chunk.cells.has(cellKey(col - 1, row)), + ); + return borderY(geo, row, side); +} + +function horizontalBoundarySide(aboveOccupied: boolean, belowOccupied: boolean): 'top' | 'bottom' | null { + if (!aboveOccupied && belowOccupied) { return 'top'; } + if (aboveOccupied && !belowOccupied) { return 'bottom'; } + return null; +} + function endpointKey(x: number, y: number): string { return `${Math.round(x)}:${Math.round(y)}`; } 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 6b1082668a..f6decfacf4 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 @@ -245,6 +245,31 @@ @if (!isLoadingTx) { @if ((cpfpInfo && hasEffectiveFeeRate) || (accelerationInfo && isAcceleration)) { + +
+ + +
+ + @if (clusterPreviewStats.chunkSize === 1) { +

This transaction belongs to a chunk of 1 transaction, with combined effective fee rate {{ clusterPreviewStats.chunkFeerate | feeRounding }} sat/vB.

+ } @else { +

This transaction belongs to a chunk of {{ clusterPreviewStats.chunkSize }} transactions, with combined effective fee rate {{ clusterPreviewStats.chunkFeerate | feeRounding }} sat/vB.

+ } + @if (clusterPreviewStats.otherChunks === 1) { +

In a mempool cluster containing 1 other chunk.

+ } @else if (clusterPreviewStats.otherChunks > 1) { +

In a mempool cluster containing {{ clusterPreviewStats.otherChunks }} other chunks.

+ } +
+
@if (isAcceleration) { Accelerated fee rate @@ -252,26 +277,33 @@ Effective fee rate } -
- @if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize || tx.acceleration)) { - - } @else { - - } +
+
+ @if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize || tx.acceleration)) { + + } @else { + + } - @if (tx?.status?.confirmed && !tx.acceleration && !accelerationInfo && tx.fee && tx.effectiveFeePerVsize) { - - } -
- @if (hasCpfp) { - @if (cpfpInfo?.cluster) { - - } @else { + @if (tx?.status?.confirmed && !tx.acceleration && !accelerationInfo && tx.fee && tx.effectiveFeePerVsize) { + + } +
+ @if (cpfpInfo?.cluster && tx?.txid && !isAcceleration) { + + } @else if (hasCpfp) { } - } +
+ @if (cpfpInfo?.cluster && tx?.txid && !isAcceleration) { + + + + + + } } } @else { diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.scss b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.scss index a062f84a42..3623c4a314 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.scss +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.scss @@ -164,3 +164,109 @@ opacity: 0.5; pointer-events: none; } + +.effective-fee-row { + &.has-cluster-preview { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + + .effective-fee-container { + flex: 0 0 auto; + } + } +} + +.cluster-preview-inline { + position: relative; + flex: 1 1 120px; + min-width: 120px; + max-width: 300px; + display: flex; + align-items: center; + overflow: visible; + margin: -0.75rem -0.75rem -0.75rem auto; + cursor: pointer; + + .cluster-preview-diagram { + display: block; + width: 100%; + --cluster-preview-cursor: pointer; + } + + .cluster-preview-expand { + position: absolute; + top: 4px; + right: 4px; + color: var(--info); + font-size: 0.85em; + opacity: 0; + transition: opacity 150ms ease; + pointer-events: none; + z-index: 1; + } + + &:focus-visible { outline: none; } + + &:hover .cluster-preview-expand, + &:focus-visible .cluster-preview-expand { + opacity: 1; + } + + &:hover .cluster-preview-diagram, + &:focus-visible .cluster-preview-diagram { + --cluster-preview-shadow: 0 0 0 1px var(--info), 0 0 8px color-mix(in srgb, var(--info) 35%, transparent); + } +} + +.cluster-preview-table-row { + display: none; +} + +@media (max-width: 849.98px) and (min-width: 500px) { + .cluster-preview-inline { + order: -1; + margin-right: 0; + } +} + +@media (max-width: 499.98px) { + .effective-fee-row.has-cluster-preview .cluster-preview-inline { + display: none; + } + + .cluster-preview-table-row { + display: table-row; + + td { + padding-top: 0; + } + + .cluster-preview-inline { + width: 100%; + flex-basis: 100%; + max-width: 100%; + margin: -0.25rem 0 -0.75rem; + } + } +} + +::ng-deep .cluster-preview-ngb-tooltip .tooltip-inner { + text-align: left; + max-width: 320px; + padding: 10px 12px; + + p { + margin: 0 0 6px; + line-height: 1.35; + + &:last-child { margin-bottom: 0; } + + &.hint { + margin-top: 8px; + opacity: 0.8; + font-style: italic; + } + } +} diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts index 0b15072cf0..39272b6847 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts @@ -40,6 +40,7 @@ export class TransactionDetailsComponent implements OnInit { @Input() isCached: boolean; @Input() ETA$: Observable; @Input() unbroadcasted: boolean; + @Input() cpfpMode: boolean = false; @Output() accelerateClicked = new EventEmitter(); @Output() toggleCpfp$ = new EventEmitter(); @@ -55,4 +56,14 @@ export class TransactionDetailsComponent implements OnInit { toggleCpfp(): void { this.toggleCpfp$.emit(); } + + get clusterPreviewStats(): { chunkSize: number; chunkFeerate: number; otherChunks: number } { + const cluster = this.cpfpInfo?.cluster; + const chunk = cluster?.chunks[cluster.chunkIndex]; + return { + chunkSize: chunk?.txs.length ?? 0, + chunkFeerate: chunk?.feerate ?? 0, + otherChunks: Math.max(0, (cluster?.chunks.length ?? 0) - 1), + }; + } } diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index 9c295f0a54..625cbf6057 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -68,7 +68,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { fetchCpfp: boolean; cpfpInfo: CpfpInfo | null; hasCpfp: boolean = false; - cpfpMode: 'advanced' | 'simple' | null = null; + cpfpMode: boolean = false; mempoolBlocksSubscription: Subscription; constructor( @@ -90,10 +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']); - const cpfpParam = this.route.snapshot.queryParams['cpfp']; - if (cpfpParam === 'advanced' || cpfpParam === 'simple') { - this.cpfpMode = cpfpParam; - } + this.cpfpMode = this.isCpfpParamEnabled(this.route.snapshot.queryParams['cpfp']); this.pushTxForm = this.formBuilder.group({ txRaw: ['', Validators.required], }); @@ -338,7 +335,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.isLoadingCpfpInfo = false; this.isLoadingBroadcast = false; this.adjustedVsize = null; - this.cpfpMode = null; + this.cpfpMode = false; this.hasCpfp = false; this.fetchCpfp = false; this.cpfpInfo = null; @@ -383,6 +380,10 @@ 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); @@ -394,15 +395,10 @@ export class TransactionRawComponent implements OnInit, OnDestroy { } toggleCpfp() { - const newMode = this.cpfpMode ? null : (this.cpfpInfo?.cluster ? 'advanced' : 'simple'); - this.updateCpfpMode(newMode); - } - - private updateCpfpMode(mode: 'advanced' | 'simple' | null) { - this.cpfpMode = mode; + this.cpfpMode = !this.cpfpMode; this.router.navigate([], { relativeTo: this.route, - queryParams: { cpfp: mode }, + queryParams: { cpfp: this.cpfpMode ? 'true' : null }, queryParamsHandling: 'merge', preserveFragment: true, replaceUrl: true, diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index a854fb7b5d..bc91457594 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -68,6 +68,7 @@

Transaction

[hasEffectiveFeeRate]="hasEffectiveFeeRate" [cpfpInfo]="cpfpInfo" [hasCpfp]="hasCpfp" + [cpfpMode]="cpfpMode" [accelerationInfo]="accelerationInfo" [replaced]="replaced" [isCached]="isCached" @@ -81,14 +82,15 @@

Transaction

+
-
-
-

Cluster

-

Related Transactions

-
+
+

Cluster

+

Related Transactions

- + +
+
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 507f232b31..7db51e9834 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -119,7 +119,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { isAcceleration: boolean = false; accelerationCanceled: boolean = false; filters: Filter[] = []; - cpfpMode: 'advanced' | 'simple' | null = null; + cpfpMode: boolean = false; miningStats: MiningStats; fetchCpfp$ = new Subject(); transactionTimes$ = new Subject(); @@ -197,6 +197,13 @@ 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, @@ -221,10 +228,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { ngOnInit() { this.enterpriseService.page(); this.isDetailsOpen = this.route.snapshot.queryParams['showDetails'] === 'true'; - const cpfpParam = this.route.snapshot.queryParams['cpfp']; - if (cpfpParam === 'advanced' || cpfpParam === 'simple') { - this.cpfpMode = cpfpParam; - } + this.cpfpMode = this.isCpfpParamEnabled(this.route.snapshot.queryParams['cpfp']); const urlParams = new URLSearchParams(window.location.search); this.forceAccelerationSummary = !!urlParams.get('cash_request_id'); @@ -1104,8 +1108,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.auditStatus = null; this.accelerationPositions = null; this.isDetailsOpen = this.route.snapshot.queryParams['showDetails'] === 'true'; - const cpfpParam = this.route.snapshot.queryParams['cpfp']; - this.cpfpMode = (cpfpParam === 'advanced' || cpfpParam === 'simple') ? cpfpParam : null; + this.cpfpMode = this.isCpfpParamEnabled(this.route.snapshot.queryParams['cpfp']); document.body.scrollTo(0, 0); this.isAcceleration = false; this.isAccelerated$.next(this.isAcceleration); @@ -1124,19 +1127,42 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } toggleCpfp() { - const newMode = this.cpfpMode ? null : (this.cpfpInfo?.cluster ? 'advanced' : 'simple'); - this.updateCpfpMode(newMode); + 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 updateCpfpMode(mode: 'advanced' | 'simple' | null) { - this.cpfpMode = mode; - this.router.navigate([], { - relativeTo: this.route, - queryParams: { cpfp: mode }, - queryParamsHandling: 'merge', - preserveFragment: true, - replaceUrl: true, - }); + private isCpfpParamEnabled(cpfpParam: string | undefined): boolean { + return cpfpParam === 'true' || cpfpParam === 'advanced' || cpfpParam === 'simple'; + } + + 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); + } + } + return fragmentParams.toString() || null; } toggleGraph() { diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 05671ff65d..9fa98bc633 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -9,7 +9,7 @@ import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, fa faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faTimeline, faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope, faExclamationTriangle, faLockOpen, faPaperclip, faAddressCard, faMedal, faBug, faFilePdf, faPiggyBank, faLayerGroup, faHeart, faCashRegister, faCodeFork, faCode, - faCalendar, faPause, faPlay} from '@fortawesome/free-solid-svg-icons'; + faCalendar, faPause, faPlay, faExpand, faCompress} from '@fortawesome/free-solid-svg-icons'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { MenuComponent } from '@components/menu/menu.component'; import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component'; @@ -498,5 +498,7 @@ export class SharedModule { library.addIcons(faCode); library.addIcons(faPause); library.addIcons(faPlay); + library.addIcons(faExpand); + library.addIcons(faCompress); } }