-
{{ hoverNode.tx.txid | shortenString }}
-
{{ hoverNode.tx.fee | number }} sat
-
-
{{ hoverNode.feerate | feeRounding }} sat/vB
+
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);
}
}