support trees of RBF replacements

This commit is contained in:
Mononaut
2022-12-17 09:39:06 -06:00
parent 9517d5fe08
commit 3e703ec13e
18 changed files with 413 additions and 219 deletions

View File

@@ -1,31 +1,54 @@
<div class="rbf-timeline box" [class.mined]="mined">
<div class="timeline">
<div class="intervals">
<ng-container *ngFor="let replacement of replacements; let i = index;">
<div class="interval" *ngIf="i > 0">
<div class="interval-time">
<app-time [time]="replacement.time - replacements[i-1].time" [relative]="false"></app-time>
</div>
</div>
<div class="node-spacer"></div>
</ng-container>
</div>
<div class="nodes">
<ng-container *ngFor="let replacement of replacements; let i = index;">
<div class="interval-spacer" *ngIf="i > 0">
<div class="track"></div>
</div>
<div class="node" [class.selected]="txid === replacement.tx.txid" [class.mined]="replacement.mined">
<div class="track"></div>
<a class="shape-border" [class.rbf]="replacement.tx.rbf" [routerLink]="['/tx/' | relativeUrl, replacement.tx.txid]" [title]="replacement.tx.txid">
<div class="shape"></div>
</a>
<span class="fee-rate">{{ replacement.tx.fee / (replacement.tx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span>
</div>
</ng-container>
<div class="rbf-timeline box" [class.mined]="replacements.mined">
<div class="timeline-wrapper">
<div class="timeline" *ngFor="let timeline of rows">
<div class="intervals">
<ng-container *ngFor="let cell of timeline; let i = index;">
<div class="node-spacer"></div>
<ng-container *ngIf="i < timeline.length - 1">
<div class="interval" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
<div class="interval-time">
<app-time [time]="cell.replacement.interval" [relative]="false"></app-time>
</div>
</div>
</ng-container>
</ng-container>
</div>
<div class="nodes">
<ng-container *ngFor="let cell of timeline; let i = index;">
<ng-container *ngIf="cell.replacement; else nonNode">
<div class="node" [class.selected]="txid === cell.replacement.tx.txid" [class.mined]="cell.replacement.tx.mined" [class.first-node]="cell.first">
<div class="track"></div>
<a class="shape-border" [class.rbf]="cell.replacement.tx.rbf" [routerLink]="['/tx/' | relativeUrl, cell.replacement.tx.txid]" [title]="cell.replacement.tx.txid">
<div class="shape"></div>
</a>
<span class="fee-rate">{{ cell.replacement.tx.fee / (cell.replacement.tx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span>
</div>
</ng-container>
<ng-template #nonNode>
<ng-container [ngSwitch]="cell.connector">
<div class="connector" *ngSwitchCase="'pipe'"><div class="pipe"></div></div>
<div class="connector" *ngSwitchCase="'corner'"><div class="corner"></div></div>
<div class="node-spacer" *ngSwitchDefault></div>
</ng-container>
</ng-template>
<ng-container *ngIf="i < timeline.length - 1">
<div class="interval-spacer" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
<div class="track"></div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</div>
<ng-template #nodeSpacer>
<div class="node-spacer"></div>
</ng-template>
<ng-template #intervalSpacer>
<div class="interval-spacer"></div>
</ng-template>
<!-- <app-rbf-timeline-tooltip
*ngIf=[tooltip]
[line]="hoverLine"

View File

@@ -23,7 +23,7 @@
background: linear-gradient(to left, #24273e, #24273e, transparent);
}
.timeline {
.timeline-wrapper {
position: relative;
width: calc(100% - 2em);
margin: auto;
@@ -44,20 +44,27 @@
align-items: flex-start;
text-align: center;
.node, .node-spacer {
width: 4em;
min-width: 4em;
.node, .node-spacer, .connector {
width: 6em;
min-width: 6em;
flex-grow: 1;
}
.interval, .interval-spacer {
width: 8em;
min-width: 4em;
min-width: 5em;
max-width: 8em;
height: 32px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-end;
}
.interval-time {
font-size: 12px;
line-height: 16px;
padding: 0 10px;
}
}
@@ -73,7 +80,7 @@
background: #105fb0;
border-radius: 5px;
}
&:first-child {
&.first-node {
.track {
left: 50%;
}
@@ -139,5 +146,24 @@
}
}
}
.connector {
position: relative;
height: 10px;
.corner, .pipe {
position: absolute;
left: -10px;
width: 20px;
height: 108px;
bottom: 50%;
border-right: solid 10px #105fb0;
}
.corner {
border-bottom: solid 10px #105fb0;
border-bottom-right-radius: 10px;
}
}
}
}

View File

@@ -1,18 +1,26 @@
import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID } from '@angular/core';
import { Router } from '@angular/router';
import { RbfInfo } from '../../interfaces/node-api.interface';
import { RbfInfo, RbfTree } from '../../interfaces/node-api.interface';
import { StateService } from '../../services/state.service';
import { ApiService } from '../../services/api.service';
type Connector = 'pipe' | 'corner';
interface TimelineCell {
replacement?: RbfInfo,
connector?: Connector,
first?: boolean,
}
@Component({
selector: 'app-rbf-timeline',
templateUrl: './rbf-timeline.component.html',
styleUrls: ['./rbf-timeline.component.scss'],
})
export class RbfTimelineComponent implements OnInit, OnChanges {
@Input() replacements: RbfInfo[];
@Input() replacements: RbfTree;
@Input() txid: string;
mined: boolean;
rows: TimelineCell[][] = [];
dir: 'rtl' | 'ltr' = 'ltr';
@@ -28,10 +36,130 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
}
ngOnInit(): void {
this.mined = this.replacements.some(entry => entry.mined);
this.rows = this.buildTimelines(this.replacements);
}
ngOnChanges(): void {
this.mined = this.replacements.some(entry => entry.mined);
this.rows = this.buildTimelines(this.replacements);
}
// converts a tree of RBF events into a format that can be more easily rendered in HTML
buildTimelines(tree: RbfTree): TimelineCell[][] {
if (!tree) return [];
const split = this.splitTimelines(tree);
const timelines = this.prepareTimelines(split);
return this.connectTimelines(timelines);
}
// splits a tree into N leaf-to-root paths
splitTimelines(tree: RbfTree, tail: RbfInfo[] = []): RbfInfo[][] {
const replacements = [...tail, tree];
if (tree.replaces.length) {
return [].concat(...tree.replaces.map(subtree => this.splitTimelines(subtree, replacements)));
} else {
return [[...replacements]];
}
}
// merges separate leaf-to-root paths into a coherent forking timeline
// represented as a 2D array of Rbf events
prepareTimelines(lines: RbfInfo[][]): RbfInfo[][] {
lines.sort((a, b) => b.length - a.length);
const rows = lines.map(() => []);
let lineGroups = [lines];
let done = false;
let column = 0; // sanity check for while loop stopping condition
while (!done && column < 100) {
// iterate over timelines element-by-element
// at each step, group lines which share a common transaction at their head
// (i.e. lines terminating in the same replacement event)
let index = 0;
let emptyCount = 0;
const nextGroups = [];
for (const group of lineGroups) {
const toMerge: { [txid: string]: RbfInfo[][] } = {};
let emptyInGroup = 0;
let first = true;
for (const line of group) {
const head = line.shift() || null;
if (first) {
// only insert the first instance of the replacement node
rows[index].unshift(head);
first = false;
} else {
// substitute duplicates with empty cells
// (we'll fill these in with connecting lines later)
rows[index].unshift(null);
}
// group the tails of the remaining lines for the next iteration
if (line.length) {
const nextId = line[0].tx.txid;
if (!toMerge[nextId]) {
toMerge[nextId] = [];
}
toMerge[nextId].push(line);
} else {
emptyInGroup++;
}
index++;
}
for (const merged of Object.values(toMerge).sort((a, b) => b.length - a.length)) {
nextGroups.push(merged);
}
for (let i = 0; i < emptyInGroup; i++) {
nextGroups.push([[]]);
}
emptyCount += emptyInGroup;
lineGroups = nextGroups;
done = (emptyCount >= rows.length);
}
column++;
}
return rows;
}
// annotates a 2D timeline array with info needed to draw connecting lines for multi-replacements
connectTimelines(timelines: RbfInfo[][]): TimelineCell[][] {
const rows: TimelineCell[][] = [];
timelines.forEach((lines, row) => {
rows.push([]);
let started = false;
let finished = false;
lines.forEach((replacement, column) => {
const cell: TimelineCell = {};
if (replacement) {
cell.replacement = replacement;
}
rows[row].push(cell);
if (replacement) {
if (!started) {
cell.first = true;
started = true;
}
} else if (started && !finished) {
if (column < timelines[row].length) {
let matched = false;
for (let i = row; i >= 0 && !matched; i--) {
const nextCell = rows[i][column];
if (nextCell.replacement) {
matched = true;
} else if (i === row) {
rows[i][column] = {
connector: 'corner'
};
} else if (nextCell.connector !== 'corner') {
rows[i][column] = {
connector: 'pipe'
};
}
}
}
finished = true;
}
});
});
return rows;
}
}