220 lines
6.8 KiB
TypeScript
220 lines
6.8 KiB
TypeScript
import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID, HostListener } from '@angular/core';
|
|
import { Router } from '@angular/router';
|
|
import { RbfTree, RbfTransaction } from '../../interfaces/node-api.interface';
|
|
import { StateService } from '../../services/state.service';
|
|
import { ApiService } from '../../services/api.service';
|
|
|
|
type Connector = 'pipe' | 'corner';
|
|
|
|
interface TimelineCell {
|
|
replacement?: RbfTree,
|
|
connector?: Connector,
|
|
first?: boolean,
|
|
fullRbf?: boolean,
|
|
}
|
|
|
|
function isTimelineCell(val: RbfTree | TimelineCell): boolean {
|
|
return !val || !('tx' in val);
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-rbf-timeline',
|
|
templateUrl: './rbf-timeline.component.html',
|
|
styleUrls: ['./rbf-timeline.component.scss'],
|
|
})
|
|
export class RbfTimelineComponent implements OnInit, OnChanges {
|
|
@Input() replacements: RbfTree;
|
|
@Input() txid: string;
|
|
@Input() rowLimit: number = 5; // If explicitly set to 0, all timelines rows will be displayed by default
|
|
rows: TimelineCell[][] = [];
|
|
timelineExpanded: boolean = this.rowLimit === 0;
|
|
|
|
hoverInfo: RbfTree | null = null;
|
|
tooltipPosition = null;
|
|
|
|
dir: 'rtl' | 'ltr' = 'ltr';
|
|
|
|
constructor(
|
|
private router: Router,
|
|
private stateService: StateService,
|
|
private apiService: ApiService,
|
|
@Inject(LOCALE_ID) private locale: string,
|
|
) {
|
|
if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
|
|
this.dir = 'rtl';
|
|
}
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
this.rows = this.buildTimelines(this.replacements);
|
|
}
|
|
|
|
ngOnChanges(changes): void {
|
|
this.rows = this.buildTimelines(this.replacements);
|
|
if (changes.txid) {
|
|
setTimeout(() => { this.scrollToSelected(); });
|
|
}
|
|
}
|
|
|
|
// converts a tree of RBF events into a format that can be more easily rendered in HTML
|
|
buildTimelines(tree: RbfTree): TimelineCell[][] {
|
|
if (!tree) return [];
|
|
|
|
this.flagFullRbf(tree);
|
|
const split = this.splitTimelines(tree);
|
|
const timelines = this.prepareTimelines(split);
|
|
return this.connectTimelines(timelines);
|
|
}
|
|
|
|
// sets the fullRbf flag on each transaction in the tree
|
|
flagFullRbf(tree: RbfTree): void {
|
|
let fullRbf = false;
|
|
for (const replaced of tree.replaces) {
|
|
if (!replaced.tx.rbf) {
|
|
fullRbf = true;
|
|
}
|
|
replaced.replacedBy = tree.tx;
|
|
this.flagFullRbf(replaced);
|
|
}
|
|
tree.tx.fullRbf = fullRbf;
|
|
}
|
|
|
|
// splits a tree into N leaf-to-root paths
|
|
splitTimelines(tree: RbfTree, tail: RbfTree[] = []): RbfTree[][] {
|
|
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: RbfTree[][]): (RbfTree | TimelineCell)[][] {
|
|
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]: RbfTree[][] } = {};
|
|
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({ connector: true, replacement: head });
|
|
}
|
|
// 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: (RbfTree | TimelineCell)[][]): TimelineCell[][] {
|
|
const rows: TimelineCell[][] = [];
|
|
timelines.forEach((lines, row) => {
|
|
rows.push([]);
|
|
let started = false;
|
|
let finished = false;
|
|
lines.forEach((replacement, column) => {
|
|
const cell: TimelineCell = {};
|
|
if (!isTimelineCell(replacement)) {
|
|
cell.replacement = replacement as RbfTree;
|
|
cell.fullRbf = (replacement as RbfTree).replacedBy?.fullRbf;
|
|
}
|
|
rows[row].push(cell);
|
|
if (!isTimelineCell(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',
|
|
fullRbf: (replacement as TimelineCell).replacement.tx.fullRbf,
|
|
};
|
|
} else if (nextCell.connector !== 'corner') {
|
|
rows[i][column] = {
|
|
connector: 'pipe',
|
|
fullRbf: (replacement as TimelineCell).replacement.tx.fullRbf,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
finished = true;
|
|
}
|
|
});
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
toggleTimeline(expand: boolean): void {
|
|
this.timelineExpanded = expand;
|
|
}
|
|
|
|
scrollToSelected() {
|
|
const node = document.getElementById('node-' + this.txid);
|
|
if (node) {
|
|
node.scrollIntoView({ block: 'nearest', inline: 'center' });
|
|
}
|
|
}
|
|
|
|
@HostListener('pointermove', ['$event'])
|
|
onPointerMove(event) {
|
|
this.tooltipPosition = { x: event.clientX, y: event.clientY };
|
|
}
|
|
|
|
onHover(event, replacement): void {
|
|
this.hoverInfo = replacement;
|
|
}
|
|
|
|
onBlur(event): void {
|
|
this.hoverInfo = null;
|
|
}
|
|
}
|