Merge branch 'master' into nymkappa/update-auto-accel-doc

This commit is contained in:
mononaut 2026-05-21 12:30:33 +09:00 committed by GitHub
commit 164896dd26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 3336 additions and 1082 deletions

View file

@ -44,7 +44,7 @@ jobs:
DEP=$(echo "$DEP" | xargs)
NEW_VERSION=$(echo "$UPDATED_DEPS_JSON" | jq -r --arg name "$DEP" '
.[] | select(.["dependency-name"] == $name) | .["new-version"]
.[] | select(.dependencyName == $name) | .newVersion
' | head -n 1)
if [ -z "$NEW_VERSION" ] || [ "$NEW_VERSION" = "null" ]; then

File diff suppressed because it is too large Load diff

View file

@ -59,19 +59,19 @@
"cypress:run:ci:parameterized": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:parameterized 4200 cypress:run:record"
},
"dependencies": {
"@angular-devkit/build-angular": "^20.3.20",
"@angular/animations": "^20.3.18",
"@angular/cli": "^20.3.20",
"@angular/common": "^20.3.18",
"@angular/compiler": "^20.3.18",
"@angular/core": "^20.3.18",
"@angular/forms": "^20.3.18",
"@angular/localize": "^20.3.18",
"@angular/platform-browser": "^20.3.18",
"@angular/platform-browser-dynamic": "^20.3.18",
"@angular/platform-server": "^20.3.18",
"@angular/router": "^20.3.18",
"@angular/ssr": "^20.3.18",
"@angular-devkit/build-angular": "^20.3.25",
"@angular/animations": "^20.3.19",
"@angular/cli": "^20.3.25",
"@angular/common": "^20.3.19",
"@angular/compiler": "^20.3.19",
"@angular/core": "^20.3.19",
"@angular/forms": "^20.3.19",
"@angular/localize": "^20.3.19",
"@angular/platform-browser": "^20.3.19",
"@angular/platform-browser-dynamic": "^20.3.19",
"@angular/platform-server": "^20.3.19",
"@angular/router": "^20.3.19",
"@angular/ssr": "^20.3.25",
"@fortawesome/angular-fontawesome": "^3.0.0",
"@fortawesome/fontawesome-common-types": "~6.7.2",
"@fortawesome/fontawesome-svg-core": "~6.7.2",
@ -91,8 +91,8 @@
"zone.js": "~0.15.1"
},
"devDependencies": {
"@angular/compiler-cli": "^20.3.18",
"@angular/language-service": "^20.3.18",
"@angular/compiler-cli": "^20.3.19",
"@angular/language-service": "^20.3.19",
"@types/node": "^24.9.2",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",

View file

@ -408,25 +408,37 @@
<div class="alliances" id="community-alliances">
<h3 i18n="about.alliances">Community Alliances</h3>
<div class="wrapper">
<a href="https://liquid.net/" title="Liquid Network">
@if (isLightMode) {
<img class="liquid" src="/resources/profile/liquid-light.svg" />
} @else {
<img class="liquid" src="/resources/profile/liquid.svg" />
}
<div class="row alliances-logos">
<a target="_blank" class="d-block col-12 col-md-6 col-lg-3 pt-2" href="https://liquid.net/" title="Liquid Network">
<div class="p-2 alliance-logo">
@if (isLightMode) {
<img src="/resources/profile/liquid-light.svg" />
} @else {
<img src="/resources/profile/liquid.svg" />
}
</div>
</a>
<a href="https://opencrypto.org/" title="Copa - Crypto Open Patent Alliance">
<img class="copa" src="/resources/profile/copa.png" />
<a target="_blank" class="d-block col-12 col-md-6 col-lg-4" href="https://opencrypto.org/" title="Copa - Crypto Open Patent Alliance" >
<div class="p-2 alliance-logo copa">
<img src="/resources/profile/copa.png" />
</div>
</a>
<a href="https://bitcoin.gob.sv" title="Oficina Nacional del Bitcoin">
@if (isLightMode) {
<img class="sv" src="/resources/profile/onbtc-full-light.svg" />
} @else {
<img class="sv" src="/resources/profile/onbtc-full.svg" />
}
<a target="_blank" class="d-block col-12 col-md-6 col-lg-2" href="https://bitcoin.gob.sv" title="Oficina Nacional del Bitcoin" >
<div class="alliance-logo">
@if (isLightMode) {
<img src="/resources/profile/onbtc-full-light.svg" />
} @else {
<img src="/resources/profile/onbtc-full.svg" />
}
</div>
</a>
<a target="_blank" class="d-block col-12 col-md-6 col-lg-3" href="https://bitcoindevkit.org/foundation/about/" title="Bitcoin Dev Kit Foundation" >
<div class="p-2 alliance-logo">
<img src="/resources/profile/bdk.svg" />
</div>
</a>
</div>
</div>
<ng-container *ngIf="translators$ | async | keyvalue as translators else loadingSponsors">

View file

@ -101,42 +101,23 @@
}
}
.alliances {
margin-bottom: 100px;
a {
&:nth-child(3) {
position: relative;
top: 10px;
}
.alliances-logos {
align-items: center;
margin-bottom: 80px;
}
.alliance-logo {
height: 80px;
display: flex;
justify-content: center;
margin-top: 25px;
@media (min-width: 768px) {
margin-top: 45px;
}
img {
display: inline-block;
margin: 15px auto;
height: 62px;
@media (min-width: 425px) {
margin: 15px 60px;
}
@media (min-width: 576px) {
margin: 15px 120px;
}
@media (min-width: 850px) {
margin: 50px 30px 0px;
}
}
.liquid {
top: 7px;
position: relative;
}
.copa {
height: auto;
top: 23px;
position: relative;
width: 300px;
}
.sv {
height: 85px;
width: auto;
}
.copa {
@media (min-width: 992px) {
position: relative;
top: 15px;
}
}

View file

@ -4,6 +4,8 @@ import { ServicesApiServices } from '@app/services/services-api.service';
export type AccelerationStats = {
totalRequested: number;
totalAccepted: number;
totalCompleted: number;
totalBidBoost: number;
totalVsize: number;
}

View file

@ -1,5 +1,5 @@
<div class="graph-container" #graphContainer>
<svg [attr.width]="svgWidth" [attr.height]="svgHeight" xmlns="http://www.w3.org/2000/svg">
<div class="graph-container" [class.preview]="preview" #graphContainer>
<svg [attr.width]="svgWidth" [attr.height]="svgHeight" [attr.viewBox]="svgViewBox" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient *ngFor="let edge of edges; trackBy: trackByEdgeId"
[attr.id]="edge.gradientId"
@ -19,75 +19,138 @@
[attr.fill]="edge.childColor"
[attr.fill-opacity]="edge.childInactive ? 0.45 : 1" />
</marker>
<marker id="edge-arrow-highlight"
<marker [attr.id]="idPrefix + '-edge-arrow-ancestor'"
markerUnits="userSpaceOnUse"
markerWidth="8" markerHeight="8"
refX="5" refY="4"
orient="auto">
<path d="M 0,0.5 L 7.5,4 L 0,7.5 Z" fill="white" />
<path class="edge-arrow-ancestor-path" d="M 0,0.5 L 7.5,4 L 0,7.5 Z" />
</marker>
<marker [attr.id]="idPrefix + '-edge-arrow-descendant'"
markerUnits="userSpaceOnUse"
markerWidth="8" markerHeight="8"
refX="5" refY="4"
orient="auto">
<path class="edge-arrow-descendant-path" d="M 0,0.5 L 7.5,4 L 0,7.5 Z" />
</marker>
<marker [attr.id]="idPrefix + '-edge-arrow-direct'"
markerUnits="userSpaceOnUse"
markerWidth="8" markerHeight="8"
refX="5" refY="4"
orient="auto">
<path class="edge-arrow-direct-path" d="M 0,0.5 L 7.5,4 L 0,7.5 Z" />
</marker>
</defs>
<path *ngFor="let outline of chunkOutlines; trackBy: trackByChunkIndex"
class="chunk-border"
[class.inactive]="outline.chunkIndex !== activeChunkIndex"
[attr.d]="outline.path" />
<ng-container *ngIf="!preview">
<path *ngFor="let outline of chunkOutlines; trackBy: trackByChunkIndex"
class="chunk-border"
[class.inactive]="outline.chunkIndex !== effectiveActiveChunk"
[attr.d]="outline.path"
[ngbTooltip]="chunkLabelTooltip"
container="body"
placement="top"
(mouseenter)="onChunkEnter(outline.chunkIndex)"
(mouseleave)="onChunkLeave()" />
<text *ngFor="let outline of chunkOutlines; trackBy: trackByChunkIndex"
class="chunk-label"
[class.inactive]="outline.chunkIndex !== activeChunkIndex"
[attr.x]="outline.labelX" [attr.y]="outline.labelY"
text-anchor="middle">
{{ outline.feerate | feeRounding }} sat/vB
</text>
<g *ngFor="let edge of edges; let i = index; trackBy: trackByEdgeId">
<path class="edge-hitarea"
[attr.d]="edge.path"
fill="none"
stroke="transparent"
stroke-width="12"
(mouseenter)="onEdgeEnter(i)"
(mouseleave)="onEdgeLeave()" />
<path class="edge-line"
[class.highlighted]="edge.highlighted"
[attr.d]="edge.path"
[attr.stroke]="edge.highlighted ? 'white' : 'url(#' + edge.gradientId + ')'"
[attr.marker-end]="edge.highlighted ? 'url(#edge-arrow-highlight)' : 'url(#' + edge.markerId + ')'"
fill="none" />
</g>
<g *ngFor="let node of nodes; trackBy: trackByNodeIndex"
class="node-group"
[class.inactive-fill]="node.inactive"
(mouseenter)="onNodeEnter(node, $event)"
(mousemove)="onNodeMove($event)"
(mouseleave)="onNodeLeave()"
(click)="onNodeClick(node)">
<rect class="node-rect"
[class.current]="node.isCurrent"
[class.hovered]="node.hovered"
[class.related]="node.related"
[attr.x]="node.rectX" [attr.y]="node.rectY"
[attr.width]="nodeW" [attr.height]="nodeH"
[attr.rx]="nodeRx"
[attr.fill]="node.color" />
<text class="node-label"
[attr.x]="node.x" [attr.y]="node.y"
text-anchor="middle" dominant-baseline="central">
{{ node.feerate | feeRounding }}
<text *ngFor="let outline of chunkOutlines; trackBy: trackByChunkIndex"
class="chunk-label"
[class.inactive]="outline.chunkIndex !== effectiveActiveChunk"
[attr.x]="outline.labelX" [attr.y]="outline.labelY"
text-anchor="middle"
[ngbTooltip]="chunkLabelTooltip"
container="body"
placement="top"
(mouseenter)="onChunkEnter(outline.chunkIndex)"
(mouseleave)="onChunkLeave()">
{{ outline.feerate | feeRounding }} sat/vB
</text>
</g>
</svg>
</ng-container>
<div #tooltip
class="cluster-tooltip"
*ngIf="hoverNode"
[style.left.px]="tooltipPosition.x"
[style.top.px]="tooltipPosition.y">
<p>{{ hoverNode.tx.txid | shortenString }}</p>
<p>{{ hoverNode.tx.fee | number }} sat</p>
<p [innerHTML]="hoverNode.tx.weight / 4 | vbytes: 2"></p>
<p>{{ hoverNode.feerate | feeRounding }} sat/vB</p>
<ng-container *ngFor="let edge of edges; let i = index; trackBy: trackByEdgeId">
<g *ngIf="edge.visible">
<path class="edge-hitarea"
[attr.d]="edge.path"
fill="none"
stroke="transparent"
stroke-width="12"
(mouseenter)="onEdgeEnter(i, $event)"
(mousemove)="onEdgeMove($event)"
(mouseleave)="onEdgeLeave()" />
<path class="edge-line"
[class.highlighted]="edge.highlighted"
[class.ancestor]="edge.highlightKind === 'ancestor'"
[class.descendant]="edge.highlightKind === 'descendant'"
[class.direct]="edge.highlightKind === 'direct'"
[attr.d]="edge.path"
[attr.stroke]="preview ? 'white' : (edge.highlighted ? null : 'url(#' + edge.gradientId + ')')"
[attr.marker-end]="preview ? null : (edge.highlightKind === 'ancestor' ? 'url(#' + idPrefix + '-edge-arrow-ancestor)' : edge.highlightKind === 'descendant' ? 'url(#' + idPrefix + '-edge-arrow-descendant)' : edge.highlightKind === 'direct' ? 'url(#' + idPrefix + '-edge-arrow-direct)' : 'url(#' + edge.markerId + ')')"
fill="none" />
</g>
</ng-container>
<ng-container *ngFor="let node of nodes; trackBy: trackByNodeIndex">
<g *ngIf="node.visible"
class="node-group"
[class.inactive-fill]="node.inactive"
(mouseenter)="onNodeEnter(node, $event)"
(mousemove)="onNodeMove($event)"
(mouseleave)="onNodeLeave()"
(click)="onNodeClick(node)">
<rect class="node-rect"
[class.current]="node.isCurrent"
[class.hovered]="node.hovered"
[class.ancestor]="node.relation === 'ancestor'"
[class.descendant]="node.relation === 'descendant'"
[attr.x]="node.rectX" [attr.y]="node.rectY"
[attr.width]="node.width" [attr.height]="node.height"
[attr.rx]="node.rx"
[attr.fill]="node.color" />
<text *ngIf="!preview" class="node-label"
[attr.x]="node.x" [attr.y]="node.y"
text-anchor="middle" dominant-baseline="central">
{{ node.feerate | feeRounding }}
</text>
</g>
</ng-container>
</svg>
</div>
<ng-template #chunkLabelTooltip><span i18n="cluster.mempool-chunk|Mempool chunk tooltip">mempool chunk</span></ng-template>
<div #tooltip
class="cluster-tooltip"
*ngIf="hoverNode || hoverEdge"
[style.left.px]="tooltipPosition.x"
[style.top.px]="tooltipPosition.y">
<ng-container *ngIf="hoverNode">
<div class="tx-id-row">
<span>{{ hoverNode.tx.txid | shortenString }}</span>
<span class="this-tx-badge" *ngIf="hoverNode.isCurrent" i18n="cluster.this-transaction|this transaction">this transaction</span>
</div>
<div class="tooltip-body">
<table class="stats">
<tr>
<th i18n="transaction.fee|Transaction fee">Fee</th>
<td>{{ hoverNode.tx.fee | number }} <span class="unit" i18n="shared.sats">sats</span></td>
</tr>
<tr>
<th i18n="transaction.vsize|Virtual size">Size</th>
<td><span [innerHTML]="hoverNode.tx.weight / 4 | vbytes: 2"></span></td>
</tr>
<tr>
<th i18n="transaction.fee-rate|Transaction fee rate">Fee rate</th>
<td>{{ hoverNode.feerate | feeRounding }} <span class="unit">sat/vB</span></td>
</tr>
</table>
<div class="legend">
<div><span class="swatch ancestor"></span><span i18n="cluster.parents|parents">parents</span></div>
<div><span class="swatch descendant"></span><span i18n="cluster.children|children">children</span></div>
</div>
</div>
</ng-container>
<div class="legend edge-legend" *ngIf="!hoverNode && hoverEdge">
<div><span class="swatch ancestor"></span><span i18n="cluster.parent|parent">parent</span></div>
<div><span class="swatch descendant"></span><span i18n="cluster.child|child">child</span></div>
</div>
</div>

View file

@ -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);
}
&.related {
stroke: rgba(255, 255, 255, 0.6);
&.ancestor {
stroke: var(--cluster-ancestor-color);
}
&.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;
p {
margin: 0;
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;
&.ancestor { background: var(--cluster-ancestor-color); }
&.descendant { background: var(--cluster-descendant-color); }
}
}

View file

@ -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<typeof setTimeout> | 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<number, number>();
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);
}

View file

@ -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)));
const targetRow = Math.max(lo, Math.min(hi, 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; }
}
}
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<string, number>,
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<number, number>();
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<T extends { minRow: number; maxRow: number }>(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 });

View file

@ -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;
virtualTopY: number;
virtualBottomY: number;
dim: RenderDimensions;
}
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;
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 svgWidth = MARGIN_X * 2 + layout.cols * cellW + totalGutterW;
const svgHeight = rowTopY[layout.rows - 1] + rowHeights[layout.rows - 1] + MARGIN_Y;
const fullWidth = dim.marginX * 2 + layout.cols * cellW + totalGutterW;
const fullHeight = rowTopY[layout.rows - 1] + rowHeights[layout.rows - 1] + bottomPad + dim.marginY;
return { nodes, edges, chunkOutlines, svgWidth, svgHeight };
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 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: 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<number, number[]>();
@ -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)}`;
}

View file

@ -245,6 +245,31 @@
<ng-template #effectiveRateRow>
@if (!isLoadingTx) {
@if ((cpfpInfo && hasEffectiveFeeRate) || (accelerationInfo && isAcceleration)) {
<ng-template #clusterPreviewButton let-cluster="cluster" let-txid="txid">
<div class="cluster-preview-inline" role="button" tabindex="0"
[ngbTooltip]="clusterPreviewTooltip"
placement="bottom"
tooltipClass="cluster-preview-ngb-tooltip"
container="body"
(click)="toggleCpfp()"
(keydown.enter)="toggleCpfp()"
(keydown.space)="toggleCpfp(); $event.preventDefault()">
<app-cluster-diagram class="cluster-preview-diagram" [cluster]="cluster" [txid]="txid" [preview]="true"></app-cluster-diagram>
<fa-icon class="cluster-preview-expand" [icon]="['fas', cpfpMode ? 'compress' : 'expand']" [fixedWidth]="true"></fa-icon>
</div>
<ng-template #clusterPreviewTooltip>
@if (clusterPreviewStats.chunkSize === 1) {
<p i18n="cluster.preview.chunk-line-one">This transaction belongs to a chunk of 1 transaction, with combined effective fee rate <strong>{{ clusterPreviewStats.chunkFeerate | feeRounding }} sat/vB</strong>.</p>
} @else {
<p i18n="cluster.preview.chunk-line-many">This transaction belongs to a chunk of {{ clusterPreviewStats.chunkSize }} transactions, with combined effective fee rate <strong>{{ clusterPreviewStats.chunkFeerate | feeRounding }} sat/vB</strong>.</p>
}
@if (clusterPreviewStats.otherChunks === 1) {
<p i18n="cluster.preview.cluster-line-one">In a mempool cluster containing 1 other chunk.</p>
} @else if (clusterPreviewStats.otherChunks > 1) {
<p i18n="cluster.preview.cluster-line-many">In a mempool cluster containing {{ clusterPreviewStats.otherChunks }} other chunks.</p>
}
</ng-template>
</ng-template>
<tr>
@if (isAcceleration) {
<td i18n="transaction.accelerated-fee-rate|Accelerated transaction fee rate">Accelerated fee rate</td>
@ -252,26 +277,33 @@
<td i18n="transaction.effective-fee-rate|Effective transaction fee rate">Effective fee rate</td>
}
<td>
<div class="effective-fee-container">
@if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize || tx.acceleration)) {
<app-fee-rate [class.oobFees]="isAcceleration" [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
} @else {
<app-fee-rate [class.oobFees]="isAcceleration" [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
}
<div class="effective-fee-row" [class.has-cluster-preview]="cpfpInfo?.cluster && tx?.txid && !isAcceleration">
<div class="effective-fee-container">
@if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize || tx.acceleration)) {
<app-fee-rate [class.oobFees]="isAcceleration" [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
} @else {
<app-fee-rate [class.oobFees]="isAcceleration" [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
}
@if (tx?.status?.confirmed && !tx.acceleration && !accelerationInfo && tx.fee && tx.effectiveFeePerVsize) {
<app-tx-fee-rating class="ms-2 me-2 effective-fee-rating" [tx]="tx"></app-tx-fee-rating>
}
</div>
@if (hasCpfp) {
@if (cpfpInfo?.cluster) {
<button type="button" class="btn btn-outline-info btn-sm btn-small-height float-end" (click)="toggleCpfp()" i18n="transaction.cluster|Cluster">Cluster</button>
} @else {
@if (tx?.status?.confirmed && !tx.acceleration && !accelerationInfo && tx.fee && tx.effectiveFeePerVsize) {
<app-tx-fee-rating class="ms-2 me-2 effective-fee-rating" [tx]="tx"></app-tx-fee-rating>
}
</div>
@if (cpfpInfo?.cluster && tx?.txid && !isAcceleration) {
<ng-container *ngTemplateOutlet="clusterPreviewButton; context: { cluster: cpfpInfo.cluster, txid: tx.txid }"></ng-container>
} @else if (hasCpfp) {
<button type="button" class="btn btn-outline-info btn-sm btn-small-height float-end" (click)="toggleCpfp()">CPFP</button>
}
}
</div>
</td>
</tr>
@if (cpfpInfo?.cluster && tx?.txid && !isAcceleration) {
<tr class="cluster-preview-table-row">
<td colspan="2">
<ng-container *ngTemplateOutlet="clusterPreviewButton; context: { cluster: cpfpInfo.cluster, txid: tx.txid }"></ng-container>
</td>
</tr>
}
}
} @else {
<ng-container *ngTemplateOutlet="skeletonDetailsRow"></ng-container>

View file

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

View file

@ -40,6 +40,7 @@ export class TransactionDetailsComponent implements OnInit {
@Input() isCached: boolean;
@Input() ETA$: Observable<ETA>;
@Input() unbroadcasted: boolean;
@Input() cpfpMode: boolean = false;
@Output() accelerateClicked = new EventEmitter<boolean>();
@Output() toggleCpfp$ = new EventEmitter<void>();
@ -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),
};
}
}

View file

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

View file

@ -68,6 +68,7 @@
[hasEffectiveFeeRate]="hasEffectiveFeeRate"
[cpfpInfo]="cpfpInfo"
[hasCpfp]="hasCpfp"
[cpfpMode]="cpfpMode"
[accelerationInfo]="accelerationInfo"
[replaced]="replaced"
[isCached]="isCached"
@ -81,14 +82,15 @@
<!-- CPFP Details -->
<ng-container *ngIf="cpfpMode && hasCpfp">
<span id="cluster" #cluster class="anchor"></span>
<br>
<div class="subtitle-block">
<div class="title">
<h2 *ngIf="cpfpInfo?.cluster" i18n="transaction.cluster|Cluster">Cluster</h2>
<h2 *ngIf="!cpfpInfo?.cluster" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
</div>
<div class="title float-start">
<h2 *ngIf="cpfpInfo?.cluster && !isAcceleration" i18n="transaction.cluster|Cluster">Cluster</h2>
<h2 *ngIf="!cpfpInfo?.cluster || isAcceleration" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
</div>
<ng-container *ngIf="cpfpInfo?.cluster; else legacyCpfp">
<button type="button" class="btn btn-outline-info btn-sm float-end" (click)="toggleCpfp()" i18n="hide">Hide</button>
<div class="clearfix"></div>
<ng-container *ngIf="cpfpInfo?.cluster && !isAcceleration; else legacyCpfp">
<div class="box">
<app-cluster-diagram
[cluster]="cpfpInfo.cluster" [txid]="tx.txid">

View file

@ -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<string>();
transactionTimes$ = new Subject<string>();
@ -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() {

View file

@ -11982,7 +11982,7 @@ export const restApiDocsData = [
codeSampleMainnet: {
esModule: [],
commonJS: [],
curl: ['txInput=ee13ebb99632377c15c94980357f674d285ac413452050031ea6dcd3e9b2dc29'],
curl: ['txid=ee13ebb99632377c15c94980357f674d285ac413452050031ea6dcd3e9b2dc29'],
headers: 'X-Mempool-Auth: stacksats',
response: `{
"txSummary": {
@ -12136,13 +12136,13 @@ export const restApiDocsData = [
Filters can be applied:<ul>
<li><code>status</code>: <code>all</code>, <code>requested</code>, <code>accelerating</code>, <code>mined</code>, <code>completed</code>, <code>failed</code></li>
<li><code>timeframe</code>: <code>24h</code>, <code>3d</code>, <code>1w</code>, <code>1m</code>, <code>3m</code>, <code>6m</code>, <code>1y</code>, <code>2y</code>, <code>3y</code>, <code>4y</code>, <code>all</code></li>
<li><code>minedByPoolUniqueId</code>: any id from <a target="_blank" href="https://github.com/mempool/mining-pools/blob/master/pools-v2.json">pools-v2.json</a>
<li><code>blockHash</code>: a block hash</a>
<li><code>blockHeight</code>: a block height</a>
<li><code>page</code>: the requested page number if using pagination <i>(min: 1)</i></a>
<li><code>pageLength</code>: the page lenght if using pagination <i>(min: 1, max: 50)</i></a>
<li><code>from</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</a>
<li><code>to</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</a>
<li><code>minedByPoolUniqueId</code>: any id from <a target="_blank" href="https://github.com/mempool/mining-pools/blob/master/pools-v2.json">pools-v2.json</a></li>
<li><code>blockHash</code>: a block hash</li>
<li><code>blockHeight</code>: a block height</li>
<li><code>page</code>: the requested page number if using pagination <i>(min: 1)</i></li>
<li><code>pageLength</code>: the page length if using pagination <i>(min: 1, max: 50)</i></li>
<li><code>from</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</li>
<li><code>to</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</li>
</ul></p>`
},
urlString: '/v1/services/accelerator/accelerations/history',
@ -12184,6 +12184,65 @@ export const restApiDocsData = [
}
}
},
{
options: { officialOnly: true },
type: 'endpoint',
category: 'accelerator-public',
httpRequestMethod: 'GET',
fragment: 'accelerator-public-stats',
title: 'GET Acceleration Stats',
description: {
default: `<p>Aggregated statistics for transaction accelerations, with optional filtering by pool, block, or time range.
Filters can be applied:<ul>
<li><code>timeframe</code>: <code>24h</code>, <code>3d</code>, <code>1w</code>, <code>1m</code>, <code>3m</code>, <code>6m</code>, <code>1y</code>, <code>2y</code>, <code>3y</code>, <code>4y</code>, <code>all</code></li>
<li><code>poolUniqueId</code>: any id from <a target="_blank" href="https://github.com/mempool/mining-pools/blob/master/pools-v2.json">pools-v2.json</a></li>
<li><code>blockHash</code>: a block hash</li>
<li><code>blockHeight</code>: a block height</li>
<li><code>from</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</li>
<li><code>to</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</li>
</ul></p>
<p>
Response fields:
<ul>
<li><code>totalRequested</code>: number of acceleration requests.</li>
<li><code>totalAccepted</code>: accelerations accepted into a block template.
<br><strong class="ms-3">- Per-pool</strong>: count this pool put in its template.
<br><strong class="ms-3">- Global</strong>: count accepted by any pool. Per-pool values do not sum to the global value.</li>
<li><code>totalCompleted</code>: accelerations mined into a confirmed block. Per-pool values sum to roughly the global value.</li>
<li><code>totalBidBoost</code>: total acceleration fees in sats.</li>
<li><code>totalVsize</code>: Total virtual size of accelerated transactions, in vbytes.</li>
</ul>
</p>
`
},
urlString: '/v1/services/accelerator/accelerations/stats',
showConditions: [''],
showJsExamples: showJsExamplesDefaultFalse,
codeExample: {
default: {
codeTemplate: {
curl: `/api/v1/services/accelerator/accelerations/stats?blockHash=000000000000000000002c55e353486c1013ce8bd6d637fd5be7798f44b731dc`,
commonJS: ``,
esModule: ``
},
codeSampleMainnet: {
esModule: [],
commonJS: [],
curl: [],
headers: '',
response: `
{
"totalRequested": 3,
"totalAccepted": 3,
"totalCompleted": 3,
"totalBidBoost": 1552,
"totalVsize": 1846
}
`,
},
},
}
},
// ACCELERATOR AUTHENTICATED
{
@ -12339,7 +12398,7 @@ export const restApiDocsData = [
codeSampleMainnet: {
esModule: [],
commonJS: [],
curl: ['txInput=ee13ebb99632377c15c94980357f674d285ac413452050031ea6dcd3e9b2dc29&userBid=21000000'],
curl: ['txid=ee13ebb99632377c15c94980357f674d285ac413452050031ea6dcd3e9b2dc29&maxBidBoost=21000000'],
headers: 'X-Mempool-Auth: stacksats',
response: `HTTP/1.1 200 OK`,
},
@ -12412,7 +12471,7 @@ export const restApiDocsData = [
codeSampleMainnet: {
esModule: [],
commonJS: [],
curl: ['txInput=ee13ebb99632377c15c94980357f674d285ac413452050031ea6dcd3e9b2dc29&type=time_delay&value=0.5'],
curl: ['txid=ee13ebb99632377c15c94980357f674d285ac413452050031ea6dcd3e9b2dc29&type=time_delay&value=0.5'],
headers: 'X-Mempool-Auth: stacksats',
response: `HTTP/1.1 200 OK`,
},

View file

@ -447,11 +447,13 @@ export interface Acceleration {
export interface AccelerationHistoryParams {
status?: string; // Single status or comma separated list of status
timeframe?: string;
poolUniqueId?: number;
minedByPoolUniqueId?: number;
blockHash?: string;
blockHeight?: number;
page?: number;
pageLength?: number;
from?: number;
to?: number;
}
export interface AccelerationInfo {

View file

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

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="127.02487mm"
height="43.305336mm"
viewBox="0 0 127.02487 43.305336"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
sodipodi:docname="bdk.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#111111"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.501319"
inkscape:cx="377.33486"
inkscape:cy="63.943772"
inkscape:window-width="2560"
inkscape:window-height="1523"
inkscape:window-x="0"
inkscape:window-y="40"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="false" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-44.027908,-126.89645)"><path
style="font-weight:600;font-size:40.9222px;line-height:0.6;font-family:Outfit;-inkscape-font-specification:'Outfit Semi-Bold';text-align:center;letter-spacing:-0.682625px;text-anchor:middle;fill:#f7931a;stroke-width:10"
d="m 95.647968,164.40015 v -4.54237 h 7.488762 q 2.00519,0 3.11009,-1.22766 1.14582,-1.22767 1.14582,-2.90548 0,-1.14582 -0.49107,-2.08703 -0.49106,-0.94121 -1.4732,-1.4732 -0.94121,-0.57291 -2.29164,-0.57291 h -7.488762 v -4.50144 h 6.874932 q 1.71873,0 2.74178,-0.85937 1.06398,-0.85937 1.06398,-2.5781 0,-1.75965 -1.06398,-2.61902 -1.02305,-0.85937 -2.74178,-0.85937 h -6.874932 v -4.54236 h 7.529682 q 2.86456,0 4.7879,1.06398 1.96427,1.02305 2.9464,2.74178 0.98213,1.67782 0.98213,3.683 0,2.45533 -1.51412,4.25591 -1.4732,1.75966 -4.33775,2.61902 l 0.24553,-1.80057 q 3.19193,0.90028 4.91066,2.98732 1.75966,2.04611 1.75966,4.95158 0,2.29165 -1.14582,4.17407 -1.1049,1.8415 -3.27378,2.98732 -2.12795,1.1049 -5.19712,1.1049 z m -4.09222,0 v -28.76831 h 5.524497 v 28.76831 z m 28.945072,0 v -4.95159 h 7.32507 q 2.78271,0 4.86974,-1.14582 2.08703,-1.18674 3.23285,-3.3147 1.18675,-2.12795 1.18675,-5.03343 0,-2.82363 -1.18675,-4.91066 -1.18674,-2.12796 -3.27377,-3.27378 -2.08703,-1.18674 -4.82882,-1.18674 h -7.44784 v -4.95159 h 7.52968 q 3.19194,0 5.8928,1.06398 2.74179,1.06397 4.74698,3.02824 2.04611,1.92334 3.151,4.54236 1.14583,2.61903 1.14583,5.72911 0,3.11009 -1.14583,5.77003 -1.10489,2.61902 -3.11008,4.58329 -2.00519,1.92334 -4.74698,2.98732 -2.70086,1.06398 -5.85187,1.06398 z m -3.80577,0 v -28.76831 h 5.56542 v 28.76831 z m 47.27818,0 -12.48127,-15.01845 12.1539,-13.74986 h 6.95677 l -13.54525,14.9366 v -2.5781 l 13.99539,16.40981 z m -17.39193,0 v -28.76831 h 5.56542 v 28.76831 z"
id="text1-3"
aria-label="BDK" /><g
id="BDK-Simple-Logo"
fill="#f7931a"
style="fill:#f7931a;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1"
transform="matrix(0.42372134,0,0,0.42372134,43.85825,126.84018)"><path
id="Shape"
d="m 2.38,0.1328 30.22,30.22 v -30.22 z" /><path
id="polygon1"
d="M 30.6204,32.3352 H 0.4004 V 2.1152 Z" /><path
id="polygon2"
d="m 2.38,35.1328 65.22,65.22 v -65.22 z" /><path
id="polygon3"
d="m 65.6204,102.3352 -65.22,-65.22 v 65.22 z" /></g></g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB