mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
Merge branch 'master' into nymkappa/update-auto-accel-doc
This commit is contained in:
commit
164896dd26
21 changed files with 3336 additions and 1082 deletions
|
|
@ -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
|
||||
|
|
|
|||
2905
frontend/package-lock.json
generated
2905
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
frontend/src/resources/profile/bdk.svg
Normal file
56
frontend/src/resources/profile/bdk.svg
Normal 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 |
Loading…
Add table
Add a link
Reference in a new issue